diff --git a/.claude/rules/manifest.json b/.claude/rules/manifest.json new file mode 100644 index 000000000..2a4c8dd65 --- /dev/null +++ b/.claude/rules/manifest.json @@ -0,0 +1,94 @@ +{ + "version": "mohxki3f", + "releaseTag": "rules-2026.04.28.1777338695005", + "generated": "2026-04-28T01:11:35.067Z", + "lastUpdated": "2026-06-25T23:40:22.941Z", + "lastUpdateReleaseTag": "rules-2026.04.28.1777338695005", + "installedDomains": [ + "security-global", + "security-lang" + ], + "files": { + "security-global/security-global-api.mdc": { + "sha256": "bc5510baa27b5aaa9998ed22a5cb35c8636eb20951eb1f4adca9fb2bd3b7cb9f", + "domain": "security-global" + }, + "security-global/security-global-auth.mdc": { + "sha256": "46d4e0b6a5c0ac5693a6346b0df52a81066d9b4d6af93b1caffacde1cff65575", + "domain": "security-global" + }, + "security-global/security-global-base.mdc": { + "sha256": "caa12169275a156517e6602213d54871c250d90e3ed56edb429d44ac63796936", + "domain": "security-global" + }, + "security-global/security-global-data-protection.mdc": { + "sha256": "cd996de2e9bd366128ab45f04d96755340aa3949114734e0fb57d7f88b71d9fc", + "domain": "security-global" + }, + "security-global/security-global-dangerous-flows.mdc": { + "sha256": "c1d5ef612881bd724362caba1d0295ffeee00ebf61f190cbd9399fcbd6683e35", + "domain": "security-global" + }, + "security-global/security-global-dependency-mgmt.mdc": { + "sha256": "c7941d14cc884098112a6fa709c0cb7bd1bdd1e6bd4d81320aa86597885ea0c4", + "domain": "security-global" + }, + "security-global/security-global-error-handling.mdc": { + "sha256": "41bff2ae7a4dce1eebd89b11a801a4a0d5f133568aeff117541b4eef932d5a57", + "domain": "security-global" + }, + "security-global/security-global-injection-prevention.mdc": { + "sha256": "f375934ea9923e7951e4796acbe42676f7db783bb326adaaec3b996ad706211f", + "domain": "security-global" + }, + "security-global/security-global-input-validation.mdc": { + "sha256": "b5eb90ff796e9d6eccbe4fb568e82e6b05d1e81df309426eaa6a7e6799c664e1", + "domain": "security-global" + }, + "security-global/security-global-mcp-usage.mdc": { + "sha256": "9eea0468b8179d3e98bfc65dd246e1f77740d0500842e6ccce5bd11ab3d19ab2", + "domain": "security-global" + }, + "security-global/security-global-output-encoding.mdc": { + "sha256": "c8e418e38ebb547675a0e9b731b42b5ee79312f91cee4267e9227613d5628748", + "domain": "security-global" + }, + "security-global/security-global-pathtraversal-prevention.mdc": { + "sha256": "801cdea14c4b81a4fc3cf60883a4f28ab24d8e077a871f66e86590ed2dba455b", + "domain": "security-global" + }, + "security-global/security-global-secure-configuration.mdc": { + "sha256": "1b6078fada5d8f435e57c14d936830b699f58b203fb1c4040e6703397a175c5d", + "domain": "security-global" + }, + "security-global/security-global-snyk.mdc": { + "sha256": "924cec9c80203e8681704a5174207bab3b6f42a3630708dd7d7fccc4fde5e1b4", + "domain": "security-global" + }, + "security-global/security-global-sql-usage.mdc": { + "sha256": "b2fcbc0a8c26625e0e2099c26e17a78ba3b01a98672a28f2fed817f71dd19b7e", + "domain": "security-global" + }, + "security-global/security-global-ssrf-prevention.mdc": { + "sha256": "0aa1c358b7e89864b5b0e6f5e1ae28934dcc48a85039d470f1d25b116e96ea54", + "domain": "security-global" + }, + "security-global/security-global-xxe-prevention.mdc": { + "sha256": "e9711b4f1db3b3c3146a21d17af4610209c4e56bb070e2c16c416c22e7fb5409", + "domain": "security-global" + }, + "security-lang/security-lang-c.mdc": { + "sha256": "96c7a93ca810550cad2e84aa9ff360d63d4fa8eb48eb849070e1b19ff9d0e2e3", + "domain": "security-lang" + }, + "security-lang/security-lang-node.mdc": { + "sha256": "9019cc2470bbfc6d01c5c34aff42498a91b59ca9766a34a79de1ebe93cc6c587", + "domain": "security-lang" + } + }, + "stats": { + "totalFiles": 19, + "missingFiles": 0, + "domains": 2 + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index bf63f4975..9e20317d4 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ packages/data/references # Playwright MCP capture artifacts .playwright-mcp/ + +# Vitest browser attachments and failure screenshots +.vitest/ diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 6459baa90..95920a247 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@adobe/data": "workspace:*", "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "license": "MIT" } diff --git a/packages/data-gpu-hopper/package.json b/packages/data-gpu-hopper/package.json index 9aa563d4b..576fe56c1 100644 --- a/packages/data-gpu-hopper/package.json +++ b/packages/data-gpu-hopper/package.json @@ -24,8 +24,8 @@ "@webgpu/types": "^0.1.61", "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vitest": "^5.0.5" } } diff --git a/packages/data-gpu-hopper/vite.config.ts b/packages/data-gpu-hopper/vite.config.ts index 9422b5857..a6ce8d478 100644 --- a/packages/data-gpu-hopper/vite.config.ts +++ b/packages/data-gpu-hopper/vite.config.ts @@ -4,9 +4,12 @@ import checker from "vite-plugin-checker"; export default defineConfig({ plugins: [checker({ typescript: true })], optimizeDeps: { - esbuildOptions: { - tsconfigRaw: { - compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + // Legacy decorators and `useDefineForClassFields: false`, as tsconfig.json sets for sources. + rolldownOptions: { + transform: { + decorator: { legacy: true }, + assumptions: { setPublicClassFields: true }, + typescript: { removeClassFieldsWithoutInitializer: true }, }, }, }, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 25a90d2b6..0eb8c95d1 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -20,7 +20,7 @@ "devDependencies": { "@webgpu/types": "^0.1.61", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5" } } diff --git a/packages/data-gpu-samples/vite.config.ts b/packages/data-gpu-samples/vite.config.ts index cff54b382..11e224a87 100644 --- a/packages/data-gpu-samples/vite.config.ts +++ b/packages/data-gpu-samples/vite.config.ts @@ -4,9 +4,12 @@ import checker from "vite-plugin-checker"; export default defineConfig({ plugins: [checker({ typescript: true })], optimizeDeps: { - esbuildOptions: { - tsconfigRaw: { - compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + // Legacy decorators and `useDefineForClassFields: false`, as tsconfig.json sets for sources. + rolldownOptions: { + transform: { + decorator: { legacy: true }, + assumptions: { setPublicClassFields: true }, + typescript: { removeClassFieldsWithoutInitializer: true }, }, }, }, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 782bdd22c..a4125eaa2 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -23,7 +23,7 @@ "devDependencies": { "@webgpu/types": "^0.1.61", "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data-lit-space-rock-game/package.json b/packages/data-lit-space-rock-game/package.json index e98ff4e78..167e8c37e 100644 --- a/packages/data-lit-space-rock-game/package.json +++ b/packages/data-lit-space-rock-game/package.json @@ -22,8 +22,8 @@ "@adobe/data-testing": "workspace:*", "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vitest": "^5.0.5" } } diff --git a/packages/data-lit-space-rock-game/vite.config.js b/packages/data-lit-space-rock-game/vite.config.js index d5aacb176..87423e1e5 100644 --- a/packages/data-lit-space-rock-game/vite.config.js +++ b/packages/data-lit-space-rock-game/vite.config.js @@ -4,9 +4,12 @@ import checker from "vite-plugin-checker"; export default defineConfig({ plugins: [checker({ typescript: true })], optimizeDeps: { - esbuildOptions: { - tsconfigRaw: { - compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + // Legacy decorators and `useDefineForClassFields: false`, as tsconfig.json sets for sources. + rolldownOptions: { + transform: { + decorator: { legacy: true }, + assumptions: { setPublicClassFields: true }, + typescript: { removeClassFieldsWithoutInitializer: true }, }, }, }, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 2987c965c..51a4d3c6a 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -22,8 +22,8 @@ "@adobe/data-testing": "workspace:*", "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vitest": "^5.0.5" } } diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app-presentation.test.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app-presentation.test.ts index c95f573a3..0f807ed1f 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app-presentation.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-app/tictactoe-app-presentation.test.ts @@ -2,10 +2,18 @@ // @vitest-environment jsdom // jsdom only so the lazy child-element wrappers (`void import(...)`) can load; // the assertions never touch the DOM. -import { describe, it, expect } from "vitest"; +import { afterAll, describe, it, expect } from "vitest"; import { Template } from "@adobe/data-lit"; import { render } from "./tictactoe-app-presentation.js"; +// The wrappers start loading their elements; let that finish before the environment is torn down. +afterAll(async () => { + await Promise.all([ + import("../tictactoe-board/tictactoe-board-element.js"), + import("../tictactoe-hud/tictactoe-hud-element.js"), + ]); +}); + describe("tictactoe-app-presentation", () => { it("mounts the board and the hud", () => { const t = Template.from(render()); diff --git a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-board/tictactoe-board-presentation.test.ts b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-board/tictactoe-board-presentation.test.ts index f7b4bc2ad..32608cdbc 100644 --- a/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-board/tictactoe-board-presentation.test.ts +++ b/packages/data-lit-tictactoe/src/features/main/ui/tictactoe-board/tictactoe-board-presentation.test.ts @@ -2,10 +2,15 @@ // @vitest-environment jsdom // jsdom only so the lazy cell wrapper (`void import(...)`) can load; the // assertions never touch the DOM. -import { describe, it, expect } from "vitest"; +import { afterAll, describe, it, expect } from "vitest"; import { Template } from "@adobe/data-lit"; import { render } from "./tictactoe-board-presentation.js"; +// The wrappers start loading their elements; let that finish before the environment is torn down. +afterAll(async () => { + await import("../tictactoe-cell/tictactoe-cell-element.js"); +}); + describe("tictactoe-board-presentation", () => { it("renders a 3x3 board of nine cells", () => { const t = Template.from(render()); diff --git a/packages/data-lit-tictactoe/vite.config.js b/packages/data-lit-tictactoe/vite.config.js index 634d9b982..88af26437 100644 --- a/packages/data-lit-tictactoe/vite.config.js +++ b/packages/data-lit-tictactoe/vite.config.js @@ -4,9 +4,12 @@ import checker from "vite-plugin-checker"; export default defineConfig({ plugins: [checker({ typescript: true })], optimizeDeps: { - esbuildOptions: { - tsconfigRaw: { - compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + // Legacy decorators and `useDefineForClassFields: false`, as tsconfig.json sets for sources. + rolldownOptions: { + transform: { + decorator: { legacy: true }, + assumptions: { setPublicClassFields: true }, + typescript: { removeClassFieldsWithoutInitializer: true }, }, }, }, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index e26b84e8a..fcb58afd4 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -30,8 +30,8 @@ "@adobe/data-testing": "workspace:*", "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vitest": "^5.0.5" } } diff --git a/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app-presentation.test.ts b/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app-presentation.test.ts index af41ebfcd..bbe38fd83 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app-presentation.test.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-app/todo-app-presentation.test.ts @@ -1,10 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // @vitest-environment jsdom // jsdom only so the lazy child-element wrappers can load; assertions never touch the DOM. -import { describe, it, expect } from "vitest"; +import { afterAll, describe, it, expect } from "vitest"; import { Template } from "@adobe/data-lit"; import { render } from "./todo-app-presentation.js"; +// The wrappers start loading their elements; let that finish before the environment is torn down. +afterAll(async () => { + await Promise.all([ + import("../todo-toolbar/todo-toolbar-element.js"), + import("../todo-list/todo-list-element.js"), + import("../../../assign/ui/users-tab/users-tab-element.js"), + ]); +}); + const props = (over: Partial[0]> = {}) => ({ tab: "todos" as const, setTab: () => {}, diff --git a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts index 9f6dd20da..ad9d8de2b 100644 --- a/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts +++ b/packages/data-lit-todo/src/features/main/ui/todo-row/todo-row-presentation.test.ts @@ -1,11 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. // @vitest-environment jsdom // jsdom only so the lazy assign wrappers can load; assertions never touch the DOM. -import { describe, it, expect } from "vitest"; +import { afterAll, describe, it, expect } from "vitest"; import { nothing } from "lit"; import { Template } from "@adobe/data-lit"; import { render } from "./todo-row-presentation.js"; +// The wrappers start loading their elements; let that finish before the environment is torn down. +afterAll(async () => { + await Promise.all([ + import("../../../assign/ui/assignee-chips/assignee-chips-element.js"), + import("../../../assign/ui/assignee-dropdown/assignee-dropdown-element.js"), + ]); +}); + const props = (over: Partial[0]> = {}) => ({ ready: true, name: "Buy milk", diff --git a/packages/data-lit-todo/vite.config.js b/packages/data-lit-todo/vite.config.js index 720deda7c..ab4c8203d 100644 --- a/packages/data-lit-todo/vite.config.js +++ b/packages/data-lit-todo/vite.config.js @@ -4,9 +4,12 @@ import checker from "vite-plugin-checker"; export default defineConfig({ plugins: [checker({ typescript: true })], optimizeDeps: { - esbuildOptions: { - tsconfigRaw: { - compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + // Legacy decorators and `useDefineForClassFields: false`, as tsconfig.json sets for sources. + rolldownOptions: { + transform: { + decorator: { legacy: true }, + assumptions: { setPublicClassFields: true }, + typescript: { removeClassFieldsWithoutInitializer: true }, }, }, }, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 901a7bcd2..2c1ff4e3e 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -28,7 +28,7 @@ "happy-dom": "^20.14.0", "lit": "^3.3.1", "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index c8c525155..161963532 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -20,8 +20,8 @@ "devDependencies": { "@adobe/data-testing": "workspace:*", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vitest": "^5.0.5" } } diff --git a/packages/data-p2p-tictactoe/vite.config.ts b/packages/data-p2p-tictactoe/vite.config.ts index aa270bbf0..3e50b6eb9 100644 --- a/packages/data-p2p-tictactoe/vite.config.ts +++ b/packages/data-p2p-tictactoe/vite.config.ts @@ -4,9 +4,12 @@ import checker from "vite-plugin-checker"; export default defineConfig({ plugins: [checker({ typescript: true })], optimizeDeps: { - esbuildOptions: { - tsconfigRaw: { - compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + // Legacy decorators and `useDefineForClassFields: false`, as tsconfig.json sets for sources. + rolldownOptions: { + transform: { + decorator: { legacy: true }, + assumptions: { setPublicClassFields: true }, + typescript: { removeClassFieldsWithoutInitializer: true }, }, }, }, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index cc56f60f1..1a4e27426 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -28,11 +28,11 @@ "@adobe/data": "workspace:*" }, "devDependencies": { - "@vitest/browser": "^1.6.0", - "playwright": "^1.46.1", + "@vitest/browser-playwright": "^5.0.5", + "playwright": "^1.64.1", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data-persistence/vite.config.js b/packages/data-persistence/vite.config.js index 8d15faace..f83aa6f3f 100644 --- a/packages/data-persistence/vite.config.js +++ b/packages/data-persistence/vite.config.js @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vite'; const IS_RUNNING_VSCODE_DEBUG = typeof process.env.VSCODE_INSPECTOR_OPTIONS === 'string'; @@ -9,13 +10,10 @@ export default defineConfig({ include: ['src/**/*.test.ts'], exclude: ['src/**/*.node.test.ts', '**/node_modules/**', '**/dist/**'], browser: IS_RUNNING_VSCODE_DEBUG ? {} : { - provider: 'playwright', + provider: playwright(), enabled: true, - name: 'chromium', + instances: [{ browser: 'chromium' }], headless: true, - options: { launch: {} }, }, - silent: false, - reporters: 'verbose', }, }); diff --git a/packages/data-persistence/vitest.config.ts b/packages/data-persistence/vitest.config.ts new file mode 100644 index 000000000..701c78f48 --- /dev/null +++ b/packages/data-persistence/vitest.config.ts @@ -0,0 +1,25 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { defineConfig } from 'vitest/config'; + +// Same suffix convention as @adobe/data: +// *.test.ts - runtime-agnostic, runs in both projects +// *.browser.test.ts - browser-only (OPFS, browser Worker) +// *.node.test.ts - node-only (node:fs, node:worker_threads) +export default defineConfig({ + test: { + silent: false, + reporters: 'verbose', + projects: [ + './vite.config.js', + { + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.node.test.ts'], + exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/dist/**'], + setupFiles: ['./test/setup-node.ts'], + }, + }, + ], + }, +}); diff --git a/packages/data-persistence/vitest.workspace.ts b/packages/data-persistence/vitest.workspace.ts deleted file mode 100644 index 981f4e1db..000000000 --- a/packages/data-persistence/vitest.workspace.ts +++ /dev/null @@ -1,21 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { defineWorkspace } from 'vitest/config'; - -// Same suffix convention as @adobe/data: -// *.test.ts - runtime-agnostic, runs in both projects -// *.browser.test.ts - browser-only (OPFS, browser Worker) -// *.node.test.ts - node-only (node:fs, node:worker_threads) -export default defineWorkspace([ - './vite.config.js', - { - test: { - name: 'node', - environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.node.test.ts'], - exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/dist/**'], - setupFiles: ['./test/setup-node.ts'], - silent: false, - reporters: 'verbose', - }, - }, -]); diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index 205b62f99..7a6006454 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -18,9 +18,9 @@ "devDependencies": { "@types/react": "^19", "@types/react-dom": "^19", - "@vitejs/plugin-react": "^4.3.0", + "@vitejs/plugin-react": "^6.1.2", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5" } } diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 6f6e47cd5..484fbd82a 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -22,11 +22,11 @@ "@adobe/data-testing": "workspace:*", "@types/react": "^19", "@types/react-dom": "^19", - "@vitejs/plugin-react": "^4.3.0", + "@vitejs/plugin-react": "^6.1.2", "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vitest": "^5.0.5" } } diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 480ea1990..04fb8429f 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -25,7 +25,7 @@ "@types/react": "^19", "react": "^19", "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data-rpc-iframe/package.json b/packages/data-rpc-iframe/package.json index 7f9ab4a77..b4651bf5d 100644 --- a/packages/data-rpc-iframe/package.json +++ b/packages/data-rpc-iframe/package.json @@ -22,8 +22,8 @@ "devDependencies": { "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vitest": "^5.0.5" } } diff --git a/packages/data-rpc-iframe/vite.config.ts b/packages/data-rpc-iframe/vite.config.ts index 9e845bbe8..2d7eff55f 100644 --- a/packages/data-rpc-iframe/vite.config.ts +++ b/packages/data-rpc-iframe/vite.config.ts @@ -7,19 +7,22 @@ import checker from "vite-plugin-checker"; export default defineConfig({ plugins: [checker({ typescript: true })], optimizeDeps: { - esbuildOptions: { - tsconfigRaw: { - compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false }, + // Legacy decorators and `useDefineForClassFields: false`, as tsconfig.json sets for sources. + rolldownOptions: { + transform: { + decorator: { legacy: true }, + assumptions: { setPublicClassFields: true }, + typescript: { removeClassFieldsWithoutInitializer: true }, }, }, }, root: ".", build: { outDir: "dist", - rollupOptions: { + rolldownOptions: { input: { - main: resolve(__dirname, "index.html"), - sub: resolve(__dirname, "sub.html"), + main: resolve(import.meta.dirname, "index.html"), + sub: resolve(import.meta.dirname, "sub.html"), }, }, }, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index 69fee63e7..e57f869f8 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -27,7 +27,7 @@ "devDependencies": { "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data-rpc/vitest.config.ts b/packages/data-rpc/vitest.config.ts new file mode 100644 index 000000000..8936d529a --- /dev/null +++ b/packages/data-rpc/vitest.config.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { defineConfig } from "vitest/config"; + +// Default project runs under node — Node 24 provides `MessageChannel`, +// `MessagePort`, and `structuredClone` as globals, so the loopback and +// MessagePort transports are exercised faithfully. Tests that need a DOM +// `window`/`MessageEvent` (the window transport) opt in per-file with +// `// @vitest-environment jsdom`. +export default defineConfig({ + test: { + silent: false, + reporters: "verbose", + projects: [ + { + test: { + name: "node", + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + }, + ], + }, +}); diff --git a/packages/data-rpc/vitest.workspace.ts b/packages/data-rpc/vitest.workspace.ts deleted file mode 100644 index 0dfc577fe..000000000 --- a/packages/data-rpc/vitest.workspace.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { defineWorkspace } from "vitest/config"; - -// Default project runs under node — Node 24 provides `MessageChannel`, -// `MessagePort`, and `structuredClone` as globals, so the loopback and -// MessagePort transports are exercised faithfully. Tests that need a DOM -// `window`/`MessageEvent` (the window transport) opt in per-file with -// `// @vitest-environment jsdom`. -export default defineWorkspace([ - { - test: { - name: "node", - environment: "node", - include: ["src/**/*.test.ts"], - exclude: ["**/node_modules/**", "**/dist/**"], - silent: false, - reporters: "verbose", - }, - }, -]); diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index e93e0aeae..e1e463b67 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -19,9 +19,9 @@ "@adobe/data-testing": "workspace:*", "jsdom": "^24.1.0", "typescript": "^5.8.3", - "vite": "^5.1.1", - "vite-plugin-checker": "^0.12.0", - "vite-plugin-solid": "^2.11.0", - "vitest": "^1.6.0" + "vite": "^8.3.4", + "vite-plugin-checker": "^0.14.5", + "vite-plugin-solid": "^2.11.14", + "vitest": "^5.0.5" } } diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 52a5ba62a..ec8a5bc5b 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -24,7 +24,7 @@ "devDependencies": { "solid-js": "^1.9.12", "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index 418c9bc87..a8154106d 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data-sync/vitest.config.ts b/packages/data-sync/vitest.config.ts new file mode 100644 index 000000000..bf9f8a4dc --- /dev/null +++ b/packages/data-sync/vitest.config.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + silent: false, + reporters: "verbose", + projects: [ + { + test: { + name: "node", + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + }, + ], + }, +}); diff --git a/packages/data-sync/vitest.workspace.ts b/packages/data-sync/vitest.workspace.ts deleted file mode 100644 index 383a7cb95..000000000 --- a/packages/data-sync/vitest.workspace.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { defineWorkspace } from "vitest/config"; - -export default defineWorkspace([ - { - test: { - name: "node", - environment: "node", - include: ["src/**/*.test.ts"], - exclude: ["**/node_modules/**", "**/dist/**"], - silent: false, - reporters: "verbose", - }, - }, -]); diff --git a/packages/data-testing/package.json b/packages/data-testing/package.json index 9c4ec1934..f0b3f85be 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -26,7 +26,7 @@ "@adobe/data": "workspace:*" }, "peerDependencies": { - "vitest": "^1.6.0" + "vitest": "^1.6.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "vitest": { @@ -35,7 +35,7 @@ }, "devDependencies": { "typescript": "^5.8.3", - "vitest": "^1.6.0" + "vitest": "^5.0.5" }, "exports": { ".": { diff --git a/packages/data/package.json b/packages/data/package.json index 44b2c4cb0..7debeb71e 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -51,7 +51,7 @@ "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitest/browser": "^1.6.0", + "@vitest/browser-playwright": "^5.0.5", "@webgpu/types": "^0.1.61", "assemblyscript": "^0.27.30", "eslint": "^8.56.0", @@ -67,7 +67,7 @@ "lint-staged": "^13.2.2", "nodemon": "^3.1.7", "npm-run-all": "^4.1.5", - "playwright": "^1.46.1", + "playwright": "^1.64.1", "prettier": "^3.2.5", "prettier-package-json": "^2.8.0", "pretty-quick": "^4.0.0", @@ -78,8 +78,8 @@ "typedoc": "^0.28.9", "typescript": "^5.8.3", "uuid": "^10.0.0", - "vite": "^5.1.1", - "vitest": "^1.6.0", + "vite": "^8.3.4", + "vitest": "^5.0.5", "webdriverio": "^9.0.9" }, "lint-staged": { diff --git a/packages/data/src/math/mat4x4/rotation-y.ts b/packages/data/src/math/mat4x4/rotation-y.ts index ae0fe3a88..6ee17aadb 100644 --- a/packages/data/src/math/mat4x4/rotation-y.ts +++ b/packages/data/src/math/mat4x4/rotation-y.ts @@ -6,9 +6,9 @@ export const rotationY = (angle: number): Mat4x4 => { const c = Math.cos(angle); const s = Math.sin(angle); return [ - c, 0, s, 0, + c, 0, -s, 0, 0, 1, 0, 0, - -s, 0, c, 0, + s, 0, c, 0, 0, 0, 0, 1 ]; }; diff --git a/packages/data/src/math/mat4x4/rotation-z.ts b/packages/data/src/math/mat4x4/rotation-z.ts index 9034eba94..171b79274 100644 --- a/packages/data/src/math/mat4x4/rotation-z.ts +++ b/packages/data/src/math/mat4x4/rotation-z.ts @@ -6,8 +6,8 @@ export const rotationZ = (angle: number): Mat4x4 => { const c = Math.cos(angle); const s = Math.sin(angle); return [ - c, -s, 0, 0, - s, c, 0, 0, + c, s, 0, 0, + -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]; diff --git a/packages/data/src/math/mat4x4/rotation.test.ts b/packages/data/src/math/mat4x4/rotation.test.ts new file mode 100644 index 000000000..511ff374e --- /dev/null +++ b/packages/data/src/math/mat4x4/rotation.test.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, expect, test } from "vitest"; +import { Mat4x4 } from "./index.js"; +import { Quat } from "../quat/index.js"; + +const close = (actual: readonly number[], expected: readonly number[]): void => + actual.forEach((value, index) => expect(value).toBeCloseTo(expected[index], 12)); + +describe("rotations", () => { + test("turn counterclockwise about their axis, as the axis-angle quaternion does", () => { + close(Mat4x4.rotationX(0.7), Quat.toMat4(Quat.fromAxisAngle([1, 0, 0], 0.7))); + close(Mat4x4.rotationY(0.7), Quat.toMat4(Quat.fromAxisAngle([0, 1, 0], 0.7))); + close(Mat4x4.rotationZ(0.7), Quat.toMat4(Quat.fromAxisAngle([0, 0, 1], 0.7))); + close(Mat4x4.multiplyVec3(Mat4x4.rotationY(Math.PI / 2), [1, 0, 0]), [0, 0, -1]); + close(Mat4x4.multiplyVec3(Mat4x4.rotationZ(Math.PI / 2), [1, 0, 0]), [0, 1, 0]); + }); +}); diff --git a/packages/data/vite.config.js b/packages/data/vite.config.js index 78ad0c1fd..fb1801c50 100644 --- a/packages/data/vite.config.js +++ b/packages/data/vite.config.js @@ -18,7 +18,5 @@ export default defineConfig({ ], setupFiles: ['./src/test-setup.ts'], tsconfig: './tsconfig-base.json', - silent: false, - reporters: 'verbose', } }) diff --git a/packages/data/vitest.config.ts b/packages/data/vitest.config.ts new file mode 100644 index 000000000..211ab96d0 --- /dev/null +++ b/packages/data/vitest.config.ts @@ -0,0 +1,32 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { defineConfig } from 'vitest/config'; + +// Test file naming convention: +// *.test.ts - runtime-agnostic, runs in both projects +// *.browser.test.ts - browser-only (DOM, WebGPU, Storage defaults, Cache API, etc.) +// *.node.test.ts - node-only (fs, worker_threads, etc.) +export default defineConfig({ + test: { + silent: false, + reporters: 'verbose', + projects: [ + // Browser project: existing vite.config.js (Playwright/Chromium). + './vite.config.js', + // Node project: pure-logic and node-specific tests. + { + test: { + name: 'node', + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.node.test.ts'], + exclude: [ + 'src/**/*.browser.test.ts', '**/node_modules/**', '**/dist/**', '**/references/**', + // See vite.config.js: the CI gate sets SKIP_PERF=1 to drop the + // non-deterministic timing-ratio perf tests. + ...(process.env.SKIP_PERF ? ['**/*.performance.test.ts'] : []), + ], + setupFiles: ['./test/setup-node.ts'], + }, + }, + ], + }, +}); diff --git a/packages/data/vitest.workspace.ts b/packages/data/vitest.workspace.ts deleted file mode 100644 index cfb2c1543..000000000 --- a/packages/data/vitest.workspace.ts +++ /dev/null @@ -1,28 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { defineWorkspace } from 'vitest/config'; - -// Test file naming convention: -// *.test.ts - runtime-agnostic, runs in both projects -// *.browser.test.ts - browser-only (DOM, WebGPU, Storage defaults, Cache API, etc.) -// *.node.test.ts - node-only (fs, worker_threads, etc.) -export default defineWorkspace([ - // Browser project: existing vite.config.js (Playwright/Chromium). - './vite.config.js', - // Node project: pure-logic and node-specific tests. - { - test: { - name: 'node', - environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.node.test.ts'], - exclude: [ - 'src/**/*.browser.test.ts', '**/node_modules/**', '**/dist/**', '**/references/**', - // See vite.config.js: the CI gate sets SKIP_PERF=1 to drop the - // non-deterministic timing-ratio perf tests. - ...(process.env.SKIP_PERF ? ['**/*.performance.test.ts'] : []), - ], - setupFiles: ['./test/setup-node.ts'], - silent: false, - reporters: 'verbose', - }, - }, -]); diff --git a/packages/ecs/CLAUDE.md b/packages/ecs/CLAUDE.md new file mode 100644 index 000000000..91009ef66 --- /dev/null +++ b/packages/ecs/CLAUDE.md @@ -0,0 +1,51 @@ +# ecs + +## Interface and implementation are separate + +The ECS has two parts: + +1. **The interface:** what consumers see and use, in `src/interface`. +2. **The implementation:** provided only by `World.create`, in `src/implementation`. + +The interface exposes only what correct usage needs. Anything only the implementation needs stays out of it: out of +the exports, and out of the types of values the interface returns. For example, a system's type is its name and the +input consumers pass to `run`. Its components, resources, kernel and body are only for the implementation, so they +aren't in its type, though the value still holds them: narrowing a type doesn't mean changing the value. + +Keeping the two apart leaves the implementation free to change how it stores, validates and runs things without +changing the interface. + +Humans own the interface and agents own the implementation: agents don't add to or widen the public contract. See +`src/interface/CLAUDE.md`. + +## Folders + +- **`src/interface`** holds every public type, the registration functions with their registry and checks, and the runtime + helpers declarations and bodies use (`Entity.schema`, the math functions and schemas, `Gpu.wgsl`, `Gpu.layout`). + It's a project of its own: nothing in it imports from outside it. Each type exists once, there. +- **`src/implementation`** holds `createWorld` and everything behind it, the kernel compiler included. It imports + types and the registry's read side from `src/interface`. +- **`src/samples`** holds one folder per sample, its page included; `pnpm sample` serves them. +- `src/index.ts` exports `src/interface`, and `World` as a value too, whose `create` and `join` are the + implementation's, so `import { World } from "@adobe/ecs"` gives consumers the type, `World.create` and `World.join`, + the only entry points. Samples and the guide use them; tests and benchmarks may take `createWorld` from + `src/implementation` for its test hooks. +- `tsc -b` emits only into `dist`; delete any `.js` or `.d.ts` that appears beside sources. + +## Naming + +- **Archetypes count as types, so they're PascalCase:** `Todo`, and in a namespace `Report.Archetype`, not + `Report.archetype`. Component and resource keys, indexes and systems are camelCase values. + +## Real-time performance + +The ECS runs every frame, so performance is a requirement, not a polish step, and it outranks every other concern: +when a measured win conflicts with a style or design preference, take the win and state the trade. Keep work per run, per system and per +row as low as it can be, and allocate nothing on those paths: reuse arrays, buffers and objects, share read-only +empties, and avoid closures, spreads and awaits there. Measure changes with `pnpm perf` against the recorded baseline. + +## Memory + +Decisions about working on this package live in `memory.md`; add new ones there. + +@memory.md diff --git a/packages/ecs/LICENSE b/packages/ecs/LICENSE new file mode 100644 index 000000000..4290d535f --- /dev/null +++ b/packages/ecs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Adobe, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ecs/design.md b/packages/ecs/design.md new file mode 100644 index 000000000..da7d33e89 --- /dev/null +++ b/packages/ecs/design.md @@ -0,0 +1,332 @@ +# ecs design + +Living document. Rough plans, decisions and open hard parts. Update as decisions are made. + +## Phases + +- **Phase 0, the contract:** define the API and its semantics, and confirm that in principle every planned feature can be implemented under it. This phase only covers things that affect the API or whether an implementation can be correct. +- **Phase 1, functional:** a correct implementation of the full contract. Performance doesn't matter yet; it may be single-threaded and uncompiled. +- **Phase 2+, performance:** optimizations that don't change the API or its semantics, added until performance is as good as it gets. After that, the core is done. + +Rule: anything that doesn't affect the API or correctness belongs in phase 2+. + +## Code layout + +- **`src/interface`** is the interface: every public type, and the registration functions with their registry (components, resources, traits, systems, rules, finalizers), plus the runtime helpers declarations and bodies use, which depend on schemas alone: `Entity.schema`, the math types and functions with their schemas, and `Gpu.wgsl` and `Gpu.layout`. Registration's checks live there too, run when a declaration is made: schemas and GPU layouts, rule strata, finalizer order and composite schedules. It's a TypeScript project of its own, and nothing in it imports from outside it. +- **`src/implementation`** is everything else: `createWorld`, which returns a `World`, the kernel compiler and its Vite plugin (`./compiler`), and the storage, scheduling and execution behind them. It imports types and the registry's read side from `src/interface`, never the other way. +- **`src/samples`** holds one folder per sample, with its code, tests and page: `src/samples/particles/`. +- **Only one copy of each type exists,** the one in `src/interface`. `src/index.ts` exports the interface, and `World` as a value whose `create` and `join` are the implementation's, so one import, `import { World } from "@adobe/ecs"`, gives the type, `World.create` and `World.join`, as every other namespace does; they're the only entry points. The guide and the samples create worlds that way; tests and benchmarks may take `createWorld` from `src/implementation`, which adds test hooks. + +## Model + +- **All state lives in the ECS.** Essential state lives in components. Derived state is computed by systems, or by structural rules, into derived components. External async ports write into the ECS through commands. +- **Change detection is Bevy-style.** Each component of each entity has `added`/`changed` ticks. Each system has a `lastRun`. `Added` / `Changed` compare against `lastRun`, so each system sees each change exactly once, wherever it runs in the schedule. +- **Structural changes are deferred.** Spawn/despawn/add/remove go to command buffers and are applied at sync points. Value writes are immediate. Commands apply in the order issued: a run's systems in schedule order, each system's by row in the order its rows run, whichever threads ran them, so of two adds of one component to one entity the later one's value stands. + +## Storage + +- Structure of Arrays component storage. +- Numeric types, tuples and structs live in linear memory backed by `SharedArrayBuffer`, so workers can read and write them. +- **Structs** (numbers, booleans, entities, fixed-length tuples and structs of those) have a fixed size and live in shared memory. **Strings** (any string, a union of string literals, either with `null`) live in shared memory too, so every thread reads them, and have no thread: a declaration giving one a `thread` is rejected, in types and at registration, and a string places no system. **Objects** (everything else: variable-length arrays, plain objects, DOM nodes, GPU objects, functions) live on one logical thread, `world` unless declared otherwise ([`threads.md`](threads.md)). + - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held from copies in the stop list, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. + - **What reads and writes them:** any system reads strings, on any thread. Only the world's thread writes them: a system placed on a joined thread that writes one is rejected there, and a command on a joined thread gives one only its start. Kernels don't read them yet, by type and check. An index keyed by strings is kept on the world's thread, by their values as objects' are, so it's looked up only there. +- Memory only reallocates at sync points. Structure is frozen between sync points, so workers never see memory move mid-phase. +- Removed rows stay in place until Apply. At Apply the last row is swap-moved into the hole and the entity lookup table is updated. + +## Execution + +- **Systems are designed to run on workers.** That is a primary constraint, not an add-on. +- **A system's declared read/write sets drive both ordering and parallelism.** +- **Systems that touch main-thread-only components are pinned to the main thread.** +- **Structural changes become visible only at sync points,** whether they come from the main thread or from workers. +- **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. +- **Kernel systems (`kernel: "cpu"` or `"gpu"`) are compiled at build time.** A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. + - It finds systems by resolving the import binding of the system factory. + - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. + - **The plugin is required for kernels.** Registering a kernel system that wasn't compiled throws. + - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name of the nearest `package.json` above the module that has one and the module's path in that package, without a query, so a workspace-linked package names no directory outside it; without a named package, a dependency's package name and path inside `node_modules`, or the path from the root; never an absolute path; `#default` names a default export. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. + - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. + - **Workers:** + - The plugin emits each kernel as its own chunk and attaches its `new URL(..., import.meta.url)` reference to the system. Served, a kernel's url is the dev server's `/@ecs-kernel/…` module, which a page and its workers import from it; served to node, as tests run, it's the kernel's module as a `data:` url, which a pool's `worker_threads` import without the server. + - On registration, workers `import()` each kernel and cache it by the world's index for its module's url, so new values of one kernel, as a module evaluated again makes, share one entry and workers' caches are bounded by code. + - Each run posts the kernel's index, chunk ranges and arguments. Workers receive the memory at startup and find archetypes and the entity table through the layout directory in it (`threads.md`), which the world's thread flushes where layouts change; only new components' schemas are posted. + - **Worker count** is the `workers` resource, `null` by default for the platform's choice, which the implementation resolves to the platform's hardware threads, `navigator.hardwareConcurrency` or node's `os.availableParallelism()`, minus the world's thread, or 0 where workers can't share memory. The pool follows it at sync points. + - **Pool workers** are browser workers, nested ones for a world on a worker, node `worker_threads`, or workers another thread lent, all behind one port (`threads.md`), so real-thread tests and benchmarks run in node too. + - **`SharedArrayBuffer` requires cross-origin isolation** (COOP/COEP headers). Without it, every system runs on the main thread. + - **Subset errors** are reported at build time. An ESLint rule shares the same validator, so errors also show in the editor. +- **GPU systems.** The built-in `gpuDevice` resource, `GPUDevice | null`, is the one device the world's GPU storage lives on, for compute and render alike: GPU buffers belong to one device, so a second device means a second world. It's an ordinary resource, given as `World.create({ gpuDevice: device })` or set later by a main-thread system; the world never destroys it. After each run the world follows it: a new device gets the GPU storage and `kernel: "gpu"` systems, after what's ahead on the old device is read back; with null, GPU kernels run on the CPU again. Render systems run only while there's a device, as if their `is` listed `gpuDevice`. A lost device makes the world set `gpuDevice` to null, as its own sync point, so nothing fails: GPU kernels run on the CPU from the CPU's copies, render systems are skipped, and systems watching `changed: [gpuDevice]` see it; GPU writes not yet read back are lost with the device. + - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a `"cpu"` kernel in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold. + - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. + - **Residency:** a separate GPU heap holds copies of only the storages GPU systems use. Each copy tracks rows the CPU changed since its upload and rows the GPU wrote since its readback. GPU systems upload what the CPU changed first, in runs of the chunks it stamped since the last upload: the components GPU systems use keep ticks, and kernels writing them keep their chunk stamps. CPU systems read back what the GPU wrote first. A sync point reads back only archetypes whose existing rows it moves or writes (despawns, adds, removes); spawns only append rows, which are uploaded alone, and a copy that outgrows its block moves on the GPU. GPU-to-GPU chains and rendering never copy back. + - **Change filters:** a GPU kernel's writes stamp each written column once with the run's tick, and `changed` filters pass a whole column when its stamp is newer than their `lastRun`, so writes on the GPU cost nothing per row on the CPU. + - **Ordering:** a queue write lands before recorded but unsubmitted work, so recorded work is submitted before each upload. Each run's encoder is submitted at its end, before the sync point; `run` resolves then, not when the GPU finishes. + - **Observer systems see values a GPU kernel wrote:** the observer pass reads back what the GPU wrote that its systems read, before they run. + - **GPU errors:** a run's GPU work, its render bodies' included, runs in validation and out-of-memory error scopes; the first error they report is a `"gpu"` failure in the next run's report, under the system whose run submitted it, since waiting for them would hold every run until the GPU process catches up. + - **Numbers on the GPU:** arithmetic is f32. Integer column reads, bitwise operators and locals assigned only integers stay i32 or u32, so integer chains keep every bit, and stores wrap as in JS. WGSL is the standard where JS differs only for NaN or infinite inputs or results: WGSL leaves those indeterminate, and GPU kernels use its own `min`, `max`, `%`, truthiness and conversions there. Where JS differs for finite values, as `Math.round` and `**` do, the GPU keeps JS's result; the math types follow WGSL instead. + +## Phase 0: contract decisions + +- **Change-tick granularity:** two ticks (`added`, `changed`) per component, not per field. Writing any field marks the whole component changed. +- **`Changed` is conservative:** it never misses a change but may report a component whose value is unchanged. Consumers must treat it as "may have changed", which keeps early cutoff a pure optimization. +- **System bodies can't carry state from one row to the next.** Cross-row results (sums, counts) need an explicit reduction construct, so work can later be split without changing semantics. +- **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. +- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource`, `Trait` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. +- **Components and resources:** + - Each is identified by an exported key, a plain `Symbol(...)`, which TypeScript types as a `unique symbol`. Its traits are declared by hand, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. Registering it, a side effect of loading its module, gives the same traits, `type` as a schema: `Component.register(parent, { type: Entity.schema, reference: "owner" })`. Its types check registration against the declaration both ways: the schema describes exactly the declared type (`Schema.ToType`, under which `Entity.schema` is `Entity`, a fixed-length array a tuple and `{ const: true }` `true`) and a default in it is of that type, every declared trait is given with its declared value, and nothing undeclared is given. A failed check names the problem where it can (`"the schema describes another type than the declared one"`). `type` is inferred from the schema alone and the checks are separate members of the parameter's type, so a registration's contextual typing never resolves them. + - **Checks of a generic key read lookups:** while a registration's key is still being inferred, contextual typing resolves its checks for a generic key, and a recursive check of a generic value, such as `IsStruct`, recurses without end. Traits read whether a key's value is a struct from a lookup computed once for every key (`Structs` in `trait/types.ts`), its keys remapped (`as K`) so a generic key's lookup stays a lookup; the checks read `Components[K]["type"]` rather than `Component.Value`, whose deferred conditional resolves the same way. + - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), and its key, a symbol too, is registered with `Trait.register`, in the registry components and resources share. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. + - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. + - **A resource always has a value:** its schema's `default` or the declaration's, beside `type` (`Resource.register(names, { type: { type: "map", keys, values }, default: new Map() })`), either of the declared type and never both, left out only when the type allows `null`, which it then starts at. + - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them, looked at five levels deep) are declared by their type alone: registration has no `type`, the types require one exactly when the declared type is data, and a resource gives its `default` on the declaration unless its type allows `null`. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. + - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. + - **Import and use:** code that references a component or resource always imports its key. That guarantees the module is loaded, so the component is registered. + - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; types reject strings too: `register` takes only symbol keys, and while `Components`, `Resources` or `Traits` has a string key that isn't a built-in trait, every component and resource declaration requires a property named for the problem (`"component keys must be symbols"`), so nothing registers until it's fixed. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). + - **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself. + - **Labels:** errors and reports name a key by its label: its description while no other registered key has it, else its id, and without one its description numbered (`#2`) in registration order, so a label is short unless it would be ambiguous. Labels are for display; nothing looks a key up by its label. +- **Archetypes** (`src/interface/archetype/`): an archetype is a readonly list of components, `readonly Component.Key[]`. `Archetype.create(components)` returns its argument, typed as the tuple given, throws for a component listed twice, and gives the list a program-wide index in a hidden property, by which worlds find what they resolved for it without comparing components. Any readonly list of components is an archetype too and goes wherever component lists do (systems, indexes, rules, finalizers); a plain list is compared by its components each time, since it may change, while an archetype must not change. A resource or string in one is a type error. The kernel compiler reads an array literal, or a constant holding one or `Archetype.create` of one, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. +- **Systems** (`src/interface/system/`; its type tests pin the behavior): + ```ts + export const follow = System.create({ + name: "ai/follow", + components: { read: [position, target], write: [velocity] }, + lookup: { targets: { read: [position] } }, + after: [integrate], // imported from the physics module + }, (row, { lookup }) => { + const other = lookup.targets(row[target]); + if (other !== null) row[velocity] = Vec3.subtract(other[position], row[position]); + }); + + export const move = System.create({ + name: "player/move", + input: { type: "object", properties: { distance: { type: "number" } } }, + components: { has: [player], write: [position] }, + }, (row, { input }) => { /* … */ }); + + export const main = System.create({ name: "main", systems: [input, physics, follow, render] }); + ``` + - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during, name }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their `name` alone. Nothing runs when they're called. + - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup`, `reduce` and `input` each appear in the context under the same name. + - **Components:** a system matches entities that have every component in `read`, `write`, `has`, `added` and `changed`, and none in `not`. + - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. + - A component in `write` is readable too, so it's never also in `read`; listing it in both is rejected in types and at registration. The same holds for resources. + - `added`/`changed` pass when **any** of the listed components was added or changed. + - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup's `changed` names components it reads or has; its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. + - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. + - **`kernel: "cpu"`** opts a system into being compiled, so it can run in chunks across workers; **`kernel: "gpu"`** narrows the subset so it can also run on the GPU. The term comes from CUDA, WebGPU and Taichi. + - It's explicit rather than inferred, so a body that leaves the subset fails the build instead of silently running on the main thread. + - Type-checked: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. + - The build validator checks `input`, since `Schema.ToType` doesn't produce tuples. + - Without `kernel`, a system is plain JS on the main thread. + - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. + - **`input`** is an object schema of named fields for a per-run, read-only value, and `run` requires it exactly when it's declared. + - A system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input. + - **`target`** names an input field holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread. + - **A composite's input** has every field of its members' inputs, and each member receives the fields it declares, so workers get only those. A field several members declare is one value for all of them, with the same schema in each: a different type is a compile-time error at each member that differs, and creating the composite throws naming both. A field optional in one and required in another is required. + - `(system name, input)` is a replicable transaction. On a GPU, input maps to a uniform buffer. + - A `Blob` input can only be stored or forwarded, because its contents can't be read synchronously. + - **Bodies are `(subject, context)`:** `(row, context)` for a system with `components` and for a finalizer, `(context)` for a system without `components`, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. + - `row`: `{ readonly entity }`, plus `read` values (read-only) and `write` values (readable and writable). No component may be named `entity`. + - `context` holds each kind under the declaration's own key, present only when the declaration declares something of that kind: `resources` (values, assignable when in `write`), `lookup` (functions by name), `reduce` (contribution functions by resource) and `input`. It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. + - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one or of `Archetype.create`, fails the build; an add of one component takes an object literal as its value. + - **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities. + - **Names are labels,** optional and not unique, so two libraries can both name a system `"linked"`; namespacing them (`"physics/integrate"`) keeps labels short. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. + - **Composites** flatten their members. + - A composite's constraints apply to every member, and members' own constraints resolve against the flattened set. + - If the same system appears twice, as one value or as values of one id (see below), it's flattened to one entry, the first reached. Systems that only share a name are different systems. + - **Observer systems** (`observer: true`) read what a sync point left, after it, on the world's thread or the joined thread they're placed on or were added on (`threads.md`): rows, resources, lookups and change filters, in no order. They write and reduce into nothing, take no input, aren't kernels or render systems, have no `up` and are ordered with nothing, in types and at creation, and their context has no `commands`. Schedules list a composite's observer members apart from its tiers, never conflicting; constraints naming one are ignored. +- **Scheduling:** + - **Ticks:** `world.add(system)` adds a system to every tick from the next one on, ordered with those already added as a composite's members are; one with no ordering path to an added system it conflicts with throws, as does one with input. A tick runs the added set as one composite, on a beat of the `cadence` resource where `always` is set, `wake` is due or a run arrived since the last tick, and only while some system is added that runs in a tier. Transactions and on-demand systems are runs. + - **The observer pass:** after each run's sync point, once its tails are done, and before `run` resolves, the world reads back what the GPU wrote that observer systems read, or the indexes they look up through hold by, then refreshes those indexes, and runs each once: the run's own, then those added, alone or within added composites, as a later value of the list each pass starts from. A system removed meanwhile, even by an earlier body of the pass, is skipped. Their throws are `"system"` failures in the next run's report, since each report's errors are written once. `add` never throws for one and keeps it out of the composite ticks run, so adding or removing one leaves that composite as it is, and it never makes the loop tick. A run of observer systems alone has no tiers; with nothing queued, no GPU and no joined thread it skips its sync point, and it doesn't wake the loop. After a tick the pass runs once its report is written, so observer systems see it, and their failures are in the next run's report; while that tick's tails or an earlier tick's run, it runs at once with the tick's values, its failures in the tick's report written once those tails are done, unless one of them reads a report component, when the tick waits for those tails first. Observer systems on joined threads are posted to their threads in the same pass, after those here ran: their failures are in a later run's report, and a joined thread's `run` of one of its own resolves once its body ran there, with its failure. + - **Removing:** `world.remove(system)` takes a value `add` was given off every tick. A system added alone and within an added composite stays until both are removed, and constraints naming a removed system are ignored, so a removal that leaves two systems that conflict with no ordering path between them throws, removing nothing, as `add` throws for one; a value not added does nothing, one no constructor returned throws, and so does a call on a joined thread for any system but an observer system. Once it returns, nothing calls the removed system's body again: a tick or run in progress reads it as removed from its next tier on, and from its next row on when its own body removed it; a run queued before skips it; and a task already posted to a joined thread holding it skips it there (`threads.md`). Its memory is released: the world forgets its schedule and runner, and those of composites only it was in, releasing what the runner held once the run or tick in progress is done, and a joined thread drops what it held for it. A later `run` of it, or of a composite holding it, registers it again, from `lastRun` 0. An observer system here whose runner holds nothing beyond its JS values (no index, order, GPU or joined thread) keeps that runner, stopped, while its value lives: added or run again once the run that removed it is done, it registers at once with that runner, restarted from `lastRun` 0, so adding it again costs no new runner, context or buffers; added again so, it runs from the next pass, even a queued run's. + - Ordering comes from `before`/`after`/`during`, sorted into tiers. Systems in the same tier may run in parallel. A constraint that names a system outside the composite being run is ignored. + - **`during` places a system alongside another:** after what that system follows and before what follows it, both by its own constraints and by others', outside a composite's own members. No-op systems ordered one after another are phases, and systems during different phases are ordered with each other. Where no ordering path separates them, the two share a tier. + - **Unordered conflicts throw when a composite is created:** within a composite, two systems that both write a component or resource, or where one reads what the other writes, must have an ordering path between them. Contributors to one reduced resource don't conflict with each other, and every system that reads it is ordered after all of them. + - **A lookup can't read a component its own system writes;** that throws at registration. `up` can, since it reads a parent only after the parent ran. + - **`up: { through, read }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what `read` lists of the row's parent, or null without one. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context. + - `through` doesn't narrow the match: rows without it are roots. References change only through commands, so the tree changes only at sync points. + - A row's depth counts its ancestors among the matched rows: a row whose parent doesn't match is a root of this system's order. + - With change filters, a row also passes when its parent reference, or what it reads of its parent, changed since the system last ran, decided on ticks, so a parent another system wrote counts too. Those components keep ticks and kernels keep their stamps. A row that moved to another archetype since then passes too, since removing its parent reference moves it, as do the children of a parent that moved to another archetype or started or stopped matching. + - Rows on or under a parent cycle don't run; reports don't list them yet. + - **Order:** rows run depth by depth, and within a depth in groups of one archetype whose parents share one archetype, each in storage order. Groups of a depth never read each other's writes, so values don't depend on the grouping; commands and reductions follow it, deterministically. The order, each row's parent row and the children of each parent change only at sync points, since only those move rows or change references. + - **Kernels read `up` straight from the parent's columns:** a call covers one group, so it binds the parent archetype's columns once, and each row reads its parent's leaf at the parent's row. A leaf's stride and offset depend only on its component's layout, so they're the same in every archetype. `up === null` holds for the whole call. + - **Filtered runs push changes down:** rows are marked from chunk ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did. + - **`kernel: "gpu"` systems may declare `up`;** what `read` lists must be made of 4-byte scalars, as the rest of a GPU kernel's values are. + - **In a main-thread body, `context.up` is one reused object,** valid only while its row runs, as `row` is. +- **World** (`src/interface/world/`): + - `World.create(resources?)` returns a world synchronously, on the creating thread, with resources starting at the given values in place of their defaults; a key that isn't a resource, or one only the world writes, throws; string keys are ignored, as in commands. Built-in resources are symbols too, so initial values import them: `World.create({ [workers]: 0 })`. + - **Built-in resources:** `gpuDevice` (`GPUDevice | null`); `workers`, the kernel pool's size, `null` by default for the platform's choice; `cadence`, `{ kind: "frame" }` by default, or `{ kind: "fixed", step, maxSteps }`; `always`, false by default; `wake`, reduced with `"min"`, Infinity for none; and `clock`, `{ now, delta, tick }`. Only the world writes `clock`: declaring a write to it is a type error, and throws at creation for untyped callers. + - **Cadence:** `frame` beats on `requestAnimationFrame` where it exists, and on a 60 per second timer otherwise. `fixed` beats every `step` milliseconds; a beat that's behind runs at most `maxSteps` ticks (4 by default) and drops the rest of the debt, and each tick's `clock.delta` is `step`. A quiet loop schedules nothing and builds no debt. + - **Clock and wakes:** before each tick the world writes `clock`: `now`, the beat's time in milliseconds (under `fixed`, the step's), `delta` since the previous tick, and `tick`, how many ran before. A system asks for a later tick by contributing a time to `wake`; the first beat at or after it runs a tick, once per wake time. A run arriving wakes the loop for the next beat; the clock's own writes never do. + - **Reports are entities** (`src/interface/report/`): every run and tick takes the next run number, from a counter of its own apart from the clock's `tick`, unique, dense and in the order they run; a run of observer systems alone takes one too. Each has a report, an entity of `Report.Archetype`: `Report.run`, a struct every thread reads, with its `number`, `elapsed` milliseconds from its start to its sync point, the `systems` that ran, the pool `workers` and `joined` threads that took part, whether any system ran on `main` (here, unless a joined thread claims it), whether it submitted `gpu` work, and the commands it `dropped`; and, only when something went wrong, `Report.errors`, an object on the world's thread holding its `failures`, `skipped` systems and `warnings`. Once a run resolves, its report is there. Reports are the world's own: queries and indexes that require no component, finalizers watching every change, and changes finalizers don't see them, as with the resource entity; a query that requires a report component does. + - **When:** a run takes its report's entity as it starts, and what that needs, a spawn or the removal of errors it can't reuse, joins the run's own sync point, as do errors known before it: system failures, skipped systems and warnings. A run of observer systems alone, with no sync point, applies a spawn as a sync point of its own. Once its sync point is done, after its tails and the GPU's readback, `Report.run` is written in place, stamped, so `changed` filters see it in that run's observer pass; `Report.errors` too when the entity holds it already, so a reused entity changes archetype only when errors come or go. A report's errors are written once: failures found after them, and the observer pass's, are in the next run's report, which a logger following `changed: [Report.errors]` sees in that run's pass; the world starts no pass for them. Failures joined threads report between runs are in the next run's report. While tails run on a joined thread, which a sync point between runs would disturb, an add or removal of errors waits for the next run's or tick's sync point, errors an earlier run left emptied meanwhile. + - **Retention:** the world keeps the last 256 runs' reports (`retainedRuns`, internal); a run past them takes the oldest report's entity, or spawns one in its place when a system despawned it. A clean run costs two clock reads and one struct write in place, about 125 ns in node; the reports' first 256 runs spawn. + - **Threads:** `world.share(port)` lets another thread join through a port (a `MessagePort`, a `Worker` or a worker's scope), returning nothing; the joined thread's progress and claims are the implementation's own storage. The other thread calls the implementation's `join(port, { threads })`, whose port and options are inferred from `share`, claiming the logical threads it fills, and gets a `World` whose `run` works as here, and whose `add` and `remove` take observer systems, which run there after the world's sync points, by the ids the plugin gives top-level systems and keys (an observer system it adds needs none, only its keys do): a run of a system without one, or one the world never imported, rejects, and a thread claimed twice, or one with a system or finalizer it can't run, without an id, naming a key without one, declaring `up` or `order`, looking up through an index with an object key part or with `changed: true`, or not imported there, can't join ([`threads.md`](threads.md)). The objects and object resources of the threads it claims are its own: commands elsewhere give them only their start or `null`, and systems elsewhere can't read them; the systems that read or write them run there, each tier waiting for it listed in the report's `warnings`, and are skipped with a `"thread"` failure once it's gone, later tiers still running. A terminal one, whose body the plugin proves never reaches `commands` and which writes only those objects, reduces into nothing and looks nothing up, runs as a tail when no later system conflicts with it: after its tick's sync point, unwarned, while the next tick goes on, a writer of what it still reads first copying the rest of its reads and waiting for at most the chunk it claimed; a run waits for its tails, and a tick's report is written once after them, with their failures. The finalizers that read them run there too, after the sync point: the world copies each stop's declared struct reads into that thread's stop list without waiting, the thread runs them as it next replays that sync, before its next task or observer post, and the index of an entity they stopped is held until the thread acknowledges; their failures are in a later run's report. When it's gone its objects come back to the world's thread, its held indexes return, stops it never finalized are `"thread"` failures, and its systems and finalizers run here again. Once the world is disposed, a joined thread finalizes every row its finalizers match. Without cross-origin isolation both throw. + - **Observer systems are the way to read a world.** A consumer reads once by running an observer system that copies what it reads into variables of its own, and follows changes by adding one whose `changed` filters pass only after the runs and ticks that changed what it reads; the world has no observers. Derived values are computed by systems, inside the ECS. + - **Observer factories** (`src/observe/`, internal, never exported: consumers write plain observer systems; its own TypeScript project importing only the package root, for tests, debugging and benchmarks) give observers as `Observe.resource(world, key)`, `Observe.entity(world, entity, { required, optional })` (no whole-entity form), `Observe.select(world, query)`, `Observe.count(world, query)` and `Observe.toPromise`, built on observer systems alone. Per world, in a `WeakMap`: one observer system per resource key and per shape of entity observer (its required and optional lists) or query (components, `where` keys, `order`, whether paged), shared by every subscriber, each `where` and page of a query an entry. A value they hold goes to a new subscriber during the subscribing call, as of the last sync point; any other waits for the flush at the end of the task, which runs what reads it (a resource's reader, a query's scan) and one delivering run, after every run called before, so cold first values are read-your-writes and come in subscribe order; a first value whose notify throws is thrown out of band and stops that subscriber, a later one's is a `"system"` failure of its run or tick. A maintenance observer system, added first, counts passes and removes shapes the passes found idle in three, so loops of first values stay warm; a resource's reader runs before it in its own run, and its notifier, filtering on the resource's `changed`, skips subscribers that had their first value in that pass. The failures of the factories' own runs, which nothing else reads, are rethrown out of band: each of their runs runs a system reading reports, `Observe/rethrow`, never added so no tick waits for it, which rethrows their systems' failures in the reports of their runs, a pass's failures once a later run of theirs sees them; on a joined thread, they're in a later report of the world's. Entity shapes look each entity up by entity with `changed`, reading values only once changed, and with no optional components skip every entity while an index without a key on the required ones reports no change. A query's first values come from one scan, an observer row system run for them, ties and unordered results put in entity order; a scan of one entry with at most one test, every entity or a count, gathers in the scanner's body into typed scratch, each value made at its length; only once a subscriber outlives its first value is the shape's tracker added: a sorted index on `order` when every term goes one direction, read from its end for descending, each run of equal keys in entity order; for a count, or a selection without a page, a sorted index on its first `where` key whose values have an order, each entry looking up its range with every bound made inclusive, so it holds every row the condition passes at the precision values are kept at, then testing each row, or for a count of one condition, taking the range's length less the rows at a bound the test rejects (rows at one key share their value, so one tells for all); otherwise an index without a key, in entity order. Each notifies only when its result differs; unordered results in entity order compare as sequences. Disposed worlds run nothing, so a first value not held never comes. `observe-cases.test.ts`, `observe-ticks-cases.test.ts`, `observe-cases.browser.test.ts` and `observe-factories.test.ts` cover them, and tests read through them (`reads.test-helper.ts`); tests of storage order, or of a joined thread's objects as of the world's last sync point, read through observer runs. Benchmarks count ticks with an observer system filtering on the clock, and read before what they time through observer runs, which leave nothing added (`perftest/observer.ts`); the factories only check results once nothing more is timed on a world, since they keep their maintenance system added there. On a joined thread's world, which the factories know by its `remove` refusing a system that isn't an observer, they add no maintenance or delivering system, since observer systems run there in posts in no order with one another: a shape's cold first values come once a run of its own system resolved, a post for it having run after every run the thread called before subscribing (a resource's notifier, whose first run once added passes its filter; an entity shape's system; a query's tracker, added on first use, with no scan, since a scan's rows can't be told from a later post's there, and for an ordered selection without a page an index without a key sorted there rather than a sorted one the world's thread keeps); a pass is a turn of the thread's event loop that ran a shape's system, idle shapes swept after it; indexes they make reach the world by declaration; a system the thread can't place, as for objects of the world's thread, throws when subscribed. On the world's thread, one naming objects a joined thread fills fails to register: an error in a run's report, or out of band for the factories' own runs, whose rejections on a live world are rethrown. `observe-joined-cases.test.ts` covers them on a joined thread, and the worker cases of `remote-objects` and `joining` on joined workers. + - **Identity is the value, and names are labels:** no name is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors and reports name one by its label: its name while no other registered system, rule or finalizer has it, else its name with its id (`"linked (lib/src/link.ts#linked)"`), and without one its name numbered (`#2`) in registration order. One declared without a name is labeled by its binding and id (`"spawnOnce (app/src/setup.ts#spawnOnce)"`), and without an id by its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). Labels are for display. + - **Ids:** an item the plugin gave an id, declared again under that id with the same kind and the same declaration, its keys and the systems and finalizers it lists matching by id, is that item, as a module evaluated again declares it: a system is a new value, with the new body, that schedules compare as the first one, so constraints from modules evaluated again still order it; a rule is the one already recorded and `Rule.create` returns it; a finalizer keeps its place in every world with the new body and `Finalizer.create` returns it. The same id with another kind or declaration throws, telling to reload the page: replacing a rule or finalizer would have to undo what worlds derived or finalized under the old one. An item without an id is only itself: declared again, it's a new item. Constraints list the systems themselves, imported from their modules; when two modules import each other, one declares the order. + - `run(system, ...input): Promise` runs a system or composite, ends in a sync point, and resolves to its run number. Runs are sequential in call order; physically, runs that don't conflict may overlap. + - It's async because workers can't be joined synchronously on the main thread. + - **Registration is the first run:** a system's declaration is checked when it's created, with the components and resources it names, which must be registered by then: every check that needs no world, its reductions, conditions, `up`, placement and render accessors included. A compiled kernel is checked against its declaration and schemas where its module registers it, and a `within` cycle when the component closing it registers. The first time a world runs a system, it gives its keys ids, adds its resources and loads its kernels, serialized with runs, and the run rejects without recording anything if that fails: a kernel the build didn't compile, a kernel module or WASM that fails to load, and a GPU kernel whose shader can't be generated from its bound columns are found there. A kernel's first run pays for its load; a run before timing work avoids it. + - **Observer systems** run after its sync point, before it resolves (see the observer pass). + - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number. + - Each system keeps its own `lastRun`, so it sees every change since it last ran. + - `dispose()` and `[Symbol.asyncDispose]` wait for pending runs, then stop ticking, terminate workers and release memory, so `await using world = ...` works. + - **Initial state** comes from the resources' schema defaults. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed. +- **A sync point runs in three phases:** + 1. **Plan.** Merge command buffers in the run's sorted system order, then chunk index, never worker ID, so conflicting commands resolve the same way every run. Expand the declared cascades and the rule closure. The result is a net plan of `fromArch → toArch` for each entity, covering the whole flush, with nothing applied yet. Only the net first→last archetype matters, so an entity that passes A→B→C produces no events for B. + 2. **Finalize, once,** against the final plan, while every row is still live (see Finalizers). + 3. **Apply.** Entries moving between one pair of source and target archetype arrive together as a block of rows in the target, then rows left are swap-removed, last first; despawned entity IDs are freed and the lookup table updated. +- **Entity IDs:** + - **`spawn` returns a real `Entity` immediately,** on the main thread or a worker. Like Bevy, it reserves the ID from the free list with an atomic cursor. The ID can be used in commands and stored in Entity-typed fields, including the spawner's own row. The entity becomes alive at Apply; until then, lookups return `undefined`. + - **ID values are local identity only** and are not deterministic across runs. Lockstep and rollback are not supported, because our applications use server-authoritative replication, and dropping them avoids placeholder IDs and a rewrite pass at Plan. + - **Replication:** replicated entities carry a stable ID component that the server assigns, and each client maps it to a local `Entity`. A client-predicted spawn gets its mapping when the server confirms it. Server deltas come from whole-plan finalizers. + - An index freed in flush N can't be reused until flush N+1, and never by reservations made before that flush. + - Per-index side data carries the generation. + - Commands against dead entities are no-ops and don't throw; the run's report counts them as `dropped`. +- **Matching, not component removal, is the unit of add/remove symmetry.** An entity stops matching a query when a required component is removed, when it's despawned, or when an excluded component is added. Per-component removal is a monitored `With`. + - Monitored queries are **structural only**. Their `Changed`/`Added` filters are stripped or rejected at the type level, and optional components don't affect matching. Value conditions become tag components set by systems. + - Replacing a required component within one flush (remove + re-add) counts as a stop plus a start. +- **Rules (structural derivation):** `Rule.create({ name, components: { has, not }, derive: [component] })`: entities matching its components have the derived components. + - **Datalog with stratified negation** over which components each entity has. Rules and `within` declarations are evaluated as one fixed point per stratum; `Rule.create` rejects a cycle through a negation, which could oscillate, and any cycle through `within`. That makes the closure a pure function of the archetype, cached per archetype, so A→B becomes a single move A→closure(B), and entities never match and then unmatch within a flush. Reference cascades run first, on the values the plan leaves; they only despawn and remove, so the result is the same fixed point. + - **Union ownership:** a derived component is present while any rule deriving it matches. + - **Derived components** (`derived: true`) are structurally read-only to user code: commands reject them in types and at runtime; systems write their values. Each starts at its schema's `default`, which the types require, stamped `added`, and a normal `added` system fills it in. No initializer phase is needed, because creation can wait for the next system run and removal can't. + - **Program-wide:** `Rule.create` checks the rule against every rule in the program, and throws there, so a world can't hold a rule set that fails. Each world starts using a rule at the first sync point after it has met every component in `has`; until then no entity can match it, so a world pays only for rules over components it uses. A rule created after entities match it, by a module imported later, applies at the next sync point. +- **Cascades** are component traits, expanded in Plan with the rules. + - **`within: host`:** the component exists only while its host does, whatever removes the host; transitively; in command order, so removing a host and adding it back ends its dependents. A derived host a rule keeps never leaves. A dependent added without its host is dropped. + - **`reference: "owner" | "weak"`** on an entity-valued component: when the target despawns, or a reference is written to an entity that won't be alive, `owner` despawns the holder and `weak` removes the component, transitively. Only the value the plan leaves counts, so a reference pointed elsewhere in the same sync point stays. References change only through commands (systems can't list them in `components.write`), which keeps the index of holders by target exact. Without `reference`, a reference dangles and a lookup of it returns null. +- **Finalizers are the only removal mechanism.** `Finalizer.create({ name, components: { read, has, not }, lookup, resources, before, after }, (row, { resources, lookup, commands }) => …)`. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. + - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct reads copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`. + - **A stop:** the entity matched before the sync point and is despawned, loses a required component by any cause (a command, a rule, `within` or a reference), has one removed and added again, or gains an excluded one. An entity spawned and despawned in the same sync point never matched. + - **Deferred handling:** a consumer that handles removals later appends what it needs (IDs, values) to a queue it owns during Finalize, and works through it whenever it runs. A consumer that tracks stops keeps state about the entities it saw start, and ignores stops for entities it doesn't know. + - **Run once, against the final plan.** Their structural commands go to the **next** sync point. Re-planning would let a later round bring back a component an earlier round already finalized, for example a freed handle on an entity that survives. + - **Rows are read-only.** Resource writes take the sync point's tick, so each system sees them once. Finalizers have no change filters, so no `lastRun`. + - **Lookups** see the state before Apply; entities spawned in the same sync point aren't alive yet. + - **Never-initialized values:** they must accept these, such as a derived handle still at its default. + - **Program-wide,** as rules are: `Finalizer.create` and `Finalizer.changes` validate and order it with every finalizer in the program, and each world starts using one once it has met every component it reads or has, or for a changes finalizer any it watches; one without them applies from the start. Its resources are added between runs. + - **Order:** `before` and `after` list other finalizers, then creation order. Two that both write a resource, or where one reads what the other writes, must be ordered. Every active finalizer runs at every sync point; none is in a composite, and the phase costs nothing when none is active. + - **Throwing:** the plan still applies, and the run's report lists each failure. + - **`dispose()`** runs per-query finalizers for every live match, so handles are released, and no whole-plan finalizers. +- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ name, watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. They are ordered with per-query finalizers. +- **Async resources** need no other API: a rule derives a slot; a system with `added: [slot]` writes a fresh token into it and starts the load; the completion calls `world.run(apply, { entity, token, value })`, which adds the ready component (`within: slot`) if the slot's token still matches and releases the value otherwise; a finalizer on the slot aborts the load, and one on the ready component releases it. Shared assets are reference-counted by the loader, since the ECS gives each user exactly one acquire and one release. + +- **Reductions** compute one value from many rows, in kernels across workers as well as on the main thread. + - **The operator is a resource trait,** `Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" })`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system repeats it, `reduce: { [total]: "+" }`; the types require the two to match and registration checks it again, so changing a resource's operator breaks every contributor. + - **The body contributes** with `reduce[total](value)`; it can't read the fold, and a contributor can't read or filter on what it reduces into. A reduced resource is never written, by systems or finalizers. + - **Each run starts again:** a run that includes a contributor sets the resource to the operator's identity, and it ends up holding the fold of every contribution in that run, stamped changed only if it differs. Several systems may contribute in one run; they don't conflict with each other, and every system that reads the resource is ordered after all of them. A run without a contributor leaves it as it is, and before the first, it holds its `default`. + - **A failed or skipped run:** when a contributor fails, or no contributor runs its rows, the resource keeps its previous value. + - **Change filters** are allowed: a contributor folds only the rows that pass. + - **Partials:** each kernel call folds into its own f64 partial, one per claimed piece across workers, which the main thread combines in piece order once the system's chunks settle; within a call, WASM kernels fold each sum, product, minimum and maximum into four lanes in turn, combined in a fixed order. Results are deterministic for one world configuration; exact for min and max, for bitwise operators on 32-bit integers, and for sums and products of integers below 2^53, while other sums and products vary with worker count, as precision does across backends. A result rounds or wraps once when stored; an empty `min` or `max` of integers stores the type's own extreme. + - **Contributions have a known shape:** a tuple or struct component contributed whole needs its leaves known, as whole-value writes do. + +- **Indexes** (`src/interface/indexes/`; its type tests pin the behavior) find rows by the values of stored components, for systems and finalizers. + - **Declared once, program-wide:** `Index.create({ name, key: [cell], components: { has, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value. + - **Lookups:** `lookup: { near: { index: grid, read: [position], changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list. + - **`changed: true`** on a system's index lookup makes the index a change filter, as a resource in `changed` is: every row passes when, since the system last ran, an entity entered or left the index or moved to another key or position in it, as its refreshes count them; a rebuild counts as a change, and a key written the same, a move to another archetype, or a change to what the index doesn't key on doesn't. With several, any one changing passes. A system whose only change filters these are, with no `up`, is skipped before its body when none changed, as when a condition fails, keeping its `lastRun`, at one comparison per such lookup, against a tick each index keeps of its last refresh that changed an entry; with other change filters its rows pass by them otherwise. Conditions still decide first. Its lists' `changed` then reports entries alone. Observer systems and CPU kernels take it, the world deciding before it calls them; finalizers, which run per entity, don't, and a system on a joined thread, or an observer placed on or added on one, is rejected, the decision needing the world's index. Systems without it pay one boolean test per run. + - **`order: index`** runs a system's rows that a sorted index holds in its order, with change filters the rows that pass; commands and reductions follow it. Not with `up`, and not on GPU kernels. + - **Scheduling and currency:** a system using an index, through a lookup or `order`, reads its key parts, for ordering, conflicts and placement, and can't write them or what it reads through the index. It sees key values written by systems ordered before it in the run, and entities as of the last sync point; a finalizer sees the index as of before its sync point applies, as its lookups see rows. + - **Unique conflicts:** a unique index keeps the entity with the lower index for a key, and reports each other as an `"index"` failure named by the index's label, with the key and both entities. + - **Lifetime:** a world builds an index the first time a registered system or finalizer uses it, in O(entities), keeps it current before each tier holding a user, and releases it, arena blocks included, with its last user, through removal and collection as runners are. + - **Threads:** kernels look up through indexes of struct keys, on the world's thread, pool workers and joined threads alike; an index with an object key part lives on the world's thread, so a system on a joined thread using one throws. `order` runs on the world's thread. + +## Phase 0: open (affects API or correctness) + +1. **Kernel subset.** Kernels compile to efficient JS or WASM. Main-thread systems run as normal JS with every feature available. + - **Parser:** the bundler's own (`this.parse`, ESTree, in both Vite and Rolldown); the compiler validates the subset on that tree, and an ESLint rule can share the validator since ESLint also produces ESTree. A second JS parser would only add size and upkeep. + - **Closed world:** every input comes from the ECS (query rows, resources). + - **Precision:** arithmetic has at least the storage precision. Results are not bit-exact across backends (JS, WASM, GPU). + - **Imported functions:** kernels call pure functions and use constants from any module the build resolves to a file, the kernel's own module included: user TypeScript or JavaScript, other packages' ESM, and the package's math types. The plugin reads each module a kernel reaches, with types stripped, follows its imports and re-exports, and inlines each call compiled in its own module's scope, so tuples are scalarized and never allocate, on JS, WASM and the GPU alike. A function outside the subset fails the build at the kernel's call, with a frame naming the file and line of each inlined function down to the failure. Recursion fails, also across modules. A module's constants must be known at build time: a `let`, or a mutation at runtime, isn't seen. Editing an inlined file compiles its kernels again, in dev too. + - **Math types:** the package exports math types that are also namespaces of functions: `Vec2`, `Vec3`, `Vec4` (each `F32`, with `I32` and `U32` nested beside it), `Quat`, `Mat4x4`, and the scalars `F32`, `I32`, `U32`, each with a `schema`. They follow WGSL's semantics, and where WGSL leaves a result indeterminate they do what is fastest: integer vectors wrap, `round` rounds halfway values to even, `mod` is `x - m * floor(x / m)`, and `normalize` of a zero vector is NaN. Integer functions are defined for integers in range, and `pow` for a positive base; other inputs give indeterminate results, which may differ between the CPU and the GPU. The few that throw (`Mat4x4.inverse`, `Mat4x4.perspective`, `Mat4x4.lookAt`) can't be used in kernels. + - **WGSL builtins:** a math function whose WGSL builtin matches it declares that explicitly, in its own file (`intrinsic({ wgsl: "normalize", parameters: ["vec3f"], result: "vec3f" }, implementation)`). In a GPU kernel a call to it becomes an intrinsic region holding its operands and its inlined body: WGSL emits the builtin on vector and matrix types, nesting calls as builtins do, and JS and WASM run the body, so CPU output is unchanged. Locals keep their JS names in WGSL, and a region's result is named after its function. + - **Helpers return early:** a `return` inside an `if` splits the block, each branch running its own statements and then the rest, and the values merge as a conditional's do. + - **Grammar:** `src/implementation/compiler/subset.md`. + - **Value semantics** for tuple and struct locals, matching component values: locals are scalarized to one local per leaf. +2. **Non-local matching.** Per row, lookups with `has` and `not` and `up` already match on another entity, such as children whose parent has X. Still open: rules with one hop through a reference component, such as a child that has `visible` while its parent does, evaluated at Plan in depth order, with the index from targets to holders re-evaluating holders when a target's archetype changes. General relationship variables (`(ChildOf, $p)` where `$p` has X) aren't planned: they make matching depend on any entity's archetype, need a reverse index per variable, and break the closure cached per archetype. +3. **GPU systems.** Pure value reads and writes into component tables, with no structural commands, so the GPU never needs command buffer communication. Still open: + - **Latency.** Whether a system can declare that its writes become visible to CPU readers only at a later sync point, so readback overlaps the next frame. + +## Additive later (no breaking change) + +- **Async ports:** a port can call `world.run(applyInput, data)`. Direct `world.commands` remains an option. + +- **Systems as entities:** metadata on systems, plus a query that selects which systems to run. +- **WGSL helper functions, a minor readability option:** emit each inlined helper as its own WGSL `fn` instead of inlining it into the kernel, as a hand-written shader would. Parameter types come from the argument shapes at each call (a 3-tuple of numbers is `vec3f`, an object a generated `struct`), so a helper called with different shapes is emitted once per shape. GPU drivers inline small functions anyway, so it changes how the shader reads, not how it runs; JS and WASM stay inlined. +- **Reductions, later:** GPU kernels (a workgroup tree reduction to per-workgroup partials, which needs resources resident on the GPU); f64x2 SIMD accumulators across rows, beyond the four scalar lanes in place, deterministic since pieces are 16-row aligned; contributing a component value of unknown shape whole, as bounds over positions want; lexicographic `min` and `max` over a tuple, for argmin and argmax such as the nearest entity; and per-field operators. +- **Rules and finalizers, later:** derived entities (rules that spawn), `within` with several hosts, finalizers as kernels, in-place writes in whole-plan finalizers, why an entity stopped, relationship conditions, and the run's report listing dropped dependents. +- **Indexes, later:** set key parts; indexes with object parts on the joined thread whose objects they hold; ordered systems on pool workers and joined threads, once commands from claimed pieces merge in piece order, and `order` on GPU kernels as uploaded row lists; zone maps, per-chunk minimums and maximums, for range filters; covering copies of read values in entry order, where measurements justify them. +- **Indexes served by other indexes, later:** where a world keeps two indexes with one key, the one holding a subset (more `has` or `not`) could be built by filtering the other's buckets by archetype flags, with no hash or sort (a sorted one in one O(n) pass); both could share one scan of the journal and key ticks per refresh, and one hash table and its encoded keys, each keeping only its own bucket entries. Across kinds, a kept sorted index on `[a, b]` could serve a hash index on `[a]`. Declarations stay as they are; only where a benchmark shows both indexes in one world. +- **A math `equals`, later:** structural equality of primitives, tuples and structs, as a math function with JS semantics, which the kernel compiler recognizes and lowers to a word-by-word comparison of scalarized values in WASM and to vector comparisons (`all(a == b)`) in WGSL. + +## Phase 2+: performance + +Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) and `pnpm test:perf` (adds chromium with workers). vitest's node transform makes every imported binding a getter, so JS-heavy paths measure about twice as slow there. + +### In place + +- **Commands:** typed parallel arrays with explicit counts, reused per system across runs; ids resolved when issued, and each list command records its shape. Kernels issue commands through `begin`/`value`/`component` by key index, allocating nothing; the compiler gives every component of a command's list a value, `undefined` for one it leaves out, which the runtime writes as the component's start. Workers write theirs into a command ring in shared memory, structs as bytes in their storages' layout and other values as tags, which the world drains at the sync point into buffers whose struct values stay bytes in the ring, copied by word as they apply (`threads.md`). +- **Kernel spawns:** staged, not commands. The compiler lists each kernel's spawn lists, and each spawn calls its list's spawner with one value per component, generated per list as main-thread spawners are. The main thread stages into batches per runner, and each chunk's buffer names the rows staged while it was current; a worker stages per task and copies its batches into its command ring after the chunk's commands. The sync point commits staged rows in merge order, right after main-thread batches and before any command, so row order doesn't depend on which thread ran a chunk. Kernel spawners reserve entities in blocks, from 8 positions doubling to 4096, so workers don't contend for the cursor; each run or task ends its block, and the sync point returns the positions it left unused to the free list with their generations. +- **Sync point:** plans straight over the queued buffers into reusable typed arrays, through cached archetype edges (arrays by component id), per-pair move lists, and for each list add or remove a table from source archetype to target; archetypes grow once per sync point; common layouts are written inline rather than through layout closures. Applying groups entries by source and target archetype: each pair copies the columns its archetypes share column by column for its block of rows and writes derived defaults once per column, then each entry's values are written. Rows left are removed last entry first, so an archetype moving out whole drops its rows without copying them; a row moved into a hole that another entry still leaves takes that entry's place. +- **Chunk ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk, stamped with every row tick, so change filters skip unchanged chunks. Stamps every row reaches are made once per kernel call, per chunk. +- **Ticks only where observed:** a component has tick columns only once some registered system filters on its `added` or `changed`, and resources always do; until then nothing allocates or stamps them, and once started they stay, bounded by the components. Columns start at the current tick, so a new system, starting with `lastRun = 0`, sees every row once. Kernels also drop `changed` stamps for components nothing watches: a count per component of the registered systems filtering on its `changed` and of the finalizers' indexes following it, an index's for the world's life. A count turning positive reloads the kernels that dropped its stamps; one falling to zero reloads nothing, and kernels drop its stamps when they next load for another reason, so a filter coming and going, as a system run once does, reloads nothing. +- **What a system holds lives as long as its value:** a world keeps its schedules and runners by system value, weakly, so a system nothing else references, such as one made for a single run, is collected with its runner, command buffers and `lastRun`, while a module's systems keep theirs between runs. What a runner holds beyond its JS values (reduction partials and `up` orders in the arena, lookup directories, GPU records, its hook after each sync point, its count of watched components) is released once its system is collected, through a finalization registry whose records hold nothing of the system. Runs read a schedule's runners again only after one was replaced or removed, as placements, device changes and `remove` do; a removed one reads as a runner that runs nothing, and a registered composite holding a system forgotten since registers again on its next run, keeping its other members' runners. Ids of keys, added resources and tick columns stay, bounded by the components; the registry forgets a collected item without an id, its label with it. `lifetime.test.ts` runs 10,000 new systems, each once, and finds every table and the heap as they were. +- **Kernels where WASM can't compile** (a content security policy without `'wasm-unsafe-eval'`) run their compiled JS. +- **Change filters inside WASM kernels:** one call per archetype or worker chunk; chunks and rows are skipped in WASM. With SIMD, outside `up`, each aligned block of eight rows checks its ticks at once and is skipped when none passes. +- **Reductions in WASM kernels:** sums, products, minimums and maximums fold consecutive rows into four lanes in turn, so a fold doesn't wait on the one before. +- **Holders:** each reference component keeps the holders of each target as linked lists by entity index, updated from the plan's reference changes at Apply, so cascades find a despawned target's holders without a scan. +- **Lookups in WASM kernels:** a directory per lookup in shared memory (per archetype: a match flag, column addresses, and for `changed` each watched component's tick column, 0 without ticks), rebuilt at sync points and once a registration starts ticks; finds check index and generation against the shared entity table. A lookup with `changed` reads its system's `lastRun` after the lookup parameters, -1 once a GPU kernel stamped a whole watched column since; JS kernels on workers read it there too, and a JS kernel's result holds `changed` after its values. +- **Dispatch:** WASM chunks go through a per-worker task ring in shared memory, signalled with `Atomics.notify`; workers stay in that window across runs until a message must reach them. A round trip to a waiting worker, `wake`, is 0.76 µs in node and 0.78–1.1 µs in chromium. +- **Joined threads:** runs through slots in shared memory, completing through a word, `remote run` 12.4 µs in node, about 2.7 µs past two threads waking each other through `Atomics.waitAsync`; its observer systems posted after each sync point, waking only a thread that read the last wake (the observer pass, below). Their objects follow the world's rows through a move log in shared memory, one entry per row moved, replayed lazily on that thread, `log replay` 21 ns a move in node, or rebuilt from the layout directory and each row's tag once it lapped, `log rebuild 100K` 3.2 ns a row; their ticks are the world's storages' alone; the structure epoch and directory counts are kept only while a joined thread holds claims. Its systems run there as tasks through its task block, consecutive tiers on it as one, both threads spinning briefly before they wait, the joined thread returning to its event loop once a tick, at about 2 µs a tick back to back: `handoff` 10.9 µs a tick of five tasks, `fused tiers` 6.9 µs a tick of one, in node. Its finalizers' stops are copied into its stop list, a column at a time, and run there as it replays: `despawn objects 10K` 83 ns a despawn on the world's thread in node, against 77 with the finalizer run there inline. Its terminal systems run as tails after the sync point, claiming rows in chunks, a late one's reads copied a run of rows at a time by the next writer: `tail prompt` 20.4 µs a tick of a 1,000-row tail, `tail late snapshot 10K` 6.7 ns a row copied, in node; the copy lets the world's work after its writer overlap the tail, `tail overlap` 2.53 ms a tick of a 2.5 ms tail and 2 ms of work against 4.70 for `tail overlap waiting`, the writer waiting for the tail instead. +- **Layout directory:** a header at offset 0, a table and a record per archetype in shared memory, flushed for the archetypes that grew or were created at the end of each sync point. Workers check one version word per archetype a task touches and rebuild views only when it moved, so growth posts no message and leaves workers' windows open: `sync point grow 100 archetypes workers` 1.43 → 0.62 ms in chromium. +- **Kernels:** interleaved layouts, SIMD within rows (SLP), across dense rows, and across rows of whole interleaved elements (four tight vec3 rows are three vectors), 16-byte loads for three-lane rows with column slack, exact integer `%`. +- **Observer factories,** in node: `read observe factory warm` 125 ns, cold 0.88 µs, adding its notifier again reusing its runner; `observe 1K entities factory` 0.54 µs a run, one changed 29; `select page 100K factory` 16 µs a change; `select page 100K churn factory` 2.8 µs a page; `count 1M changed factory` 42 µs; `select factory` 7.5 ms. +- **Main-thread spawns and adds:** a list of components resolves to a shape shared by every list of the same components: an archetype by its index, in a table per world, and a plain list by its keys among the last eight plain lists. Spawns are staged into a batch per component set and block-copied at the sync point. A shape's spawner is generated code, one straight-line read by key and store per value, with only numbers written into the code and keys, columns and writers passed in, so each read sees one object shape and each store one layout; where a content security policy forbids `'unsafe-eval'`, it's a loop over the shape's columns, about twice as slow (`perf.md`). The plugin rewrites `commands.spawn(components)({ ... })` and `commands.add(entity, components, { ... })` with literal keys in main-thread systems into a per-site function with one parameter per value, so no values object is built: a spawn calls a spawner generated for the site's key order and the list's shape, kept per site by archetype index and by shape; an add appends each value to the command buffer by the component ids it resolved once per world and list. A site's keys are read in module scope, outside every binding the generated code declares, and a kernel's locals are printed with their ids, so no name of the module's or the body's can capture or be captured by one the compiler generates. No keys are listed on these paths; keys are listed only per query (`where`, `order`) and per world (`World.create`'s values). +- **Main-thread systems:** one accessor row per set of reads and writes, in fast properties, shared across systems and archetypes and rebound per pass; getters and setters specialized per layout; resource cells resolved once per run. A run starts on the runner's first command buffer and chunk without staging, sets its chunks' length only when it differs, and checks conditions only when it has some: `run 50 idle` 110 → 73 ns a system. +- **The observer pass:** each list of observer systems keeps its runners, read again once runners change, and whether any looks up through an index, so an idle pass reads no index and no GPU state: `observer idle 100` about 80 ns an observer system a tick, and a run of observer systems alone skips its empty sync point: `run observer` 0.21 µs against 0.38–0.42 for `run idle`. One on a joined thread is posted only when its conditions hold and a filter may pass, by its resources' and matched chunks' ticks, into a block of its own, waking each thread once a pass; that thread reads the shared memory directly, the next writer copying what it hasn't read yet, or, for one reading only struct resources, reads copies the post holds, written into one of two blocks so the world never waits for it: `joined observer resource` 4.3–5.0 µs a tick and `joined observer 10K` 0.41–0.51 ms in node; 7.2–7.5 µs and 0.34–0.50 ms in chromium. +- **Indexes:** + - Keys are encoded as order-preserving words (f32 and f64 bits flipped, NaN last and -0 as +0, i32 with its sign bit flipped, booleans 0 or 1, object parts as ids interned per index), hashed by a multiply-xorshift mix, the same in JS and WASM. + - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. + - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets one by one and long ones in one merge, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again. + - Built again in four phases over fixed pieces of entity indexes and fixed partitions of the slots, so the index is the same whatever the number of workers: counting each piece's members by partition, listing them into partition order through code generated for the key's leaves, grouping each partition's keys into buckets, and filling its range of the table. Workers claim phases through their task rings; with no pool the world's thread runs them: `index rebuild 1M` 27 ms on the world's thread, 6.7 ms across nine workers. Sorted indexes are built on the world's thread, by LSD radix sort over 16-bit digits. + - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and on the world's thread rows check one count of replaced arrays rather than their archetype's version: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`. + - Kernels compile each lookup call into a probe the world binds to its index's layout; WASM probes in straight-line code, reading the index's arrays at addresses each run's parameters give, on the world's thread and pool workers; joined threads read a record of them the world writes before each task: `grid neighbors 100K` 3.7 µs a row on the world's thread, 179 ns a row in a kernel across the pool. + - `order`: main-thread bodies walk the index's entries; filtered runs sort passing rows' positions while few pass and scan past an eighth; kernels run lists of rows as `up`'s do: `order run 100K` 31 ns a row against 28 in storage order. +- **`up`:** + - Kept current at sync points, as `up-order.md` describes. Sync points journal the entity indexes they spawn, despawn, move to another archetype, swap-move or write a reference for, while some `up` system or index reads the journal. + - Each system keeps a tree by entity index (parent, level, children as linked lists), read from the journal; levels settle by walking up to a root or a settled entity, which also finds cycles. + - The order: groups of one depth, archetype and parent archetype, as segments of one arena buffer with spare slots, patched in two phases by binary search and block moves within a group, so rows keep storage order. + - Rebuilt in full, in O(rows), when the journal dropped entries a system hadn't read, or when more than a quarter of the order's rows would move. + - Filtered runs mark entities into lists by depth, from chunk ticks and the journal; a depth with few marks is sorted, one with many is scanned in order. + - WASM kernels run a group's row list, with the parent archetype's column addresses as parameters. + - Workers take one depth at a time as claimed pieces, with a barrier per depth; depths too small to split run on the main thread without a wait. + - The GPU runs a dispatch per group, in order, within one pass; row lists live in the heap and upload only when the order changes. + - Main-thread bodies read parents through one reused accessor row. + +### Next + +- **U32 ticks,** if tick bandwidth matters for observed components. Halves tick memory and the change-filter scan's bandwidth, which bounds uniformly sparse filters. Comparisons use wrapping subtraction relative to the current tick, with a periodic clamp pass at a sync point, like Bevy's `check_change_ticks`, covering cell, `lastRun` and chunk ticks. +- **Compiled main-thread systems.** Accessor rows copy tuples out on every read and share one megamorphic body call site; compiling main-thread systems (or a JS-only kernel mode) would remove both. A per-system loop alone measured 5–10%. +- **Kernel fusion, once real workloads show fusable pipelines.** Consecutive per-row kernels over the same archetypes, each reading what the previous one wrote at the same row, become one pass. It cuts dispatch and memory passes: chain 6×1M spends its time streaming 12 MB six times. Fusion must keep each system's tick, `lastRun`, change filters and command order. +- **`up`:** + - **Uploading only patched groups to the GPU,** once large GPU trees change structure every frame: the whole order uploads when it changes. + - **Deep, narrow trees, not planned until a workload needs them.** They cost a barrier per depth on workers and about 4.5 µs per dispatch on the GPU. Workers could take whole subtrees each, with no barriers, and the GPU could use pointer jumping for associative compositions such as matrix products: log(depth) dispatches, each composing a row with its 2^k-th ancestor. + - **Parent values copied into a dense column per group, not planned.** It would let kernels vectorize across rows, but the copy does the same scattered parent reads the kernel does, adds a pass and a barrier per depth, and costs rows × bytes read (64 MB for a mat4 over 1M rows). Wide fan-out already keeps a parent in cache, and heavy bodies such as mat4 products already vectorize within a row, so the estimated gain is under about 15% for compute-heavy bodies, and a loss for light ones. +- **WASM commands.** Encode kernel commands into a shared-memory buffer from WASM, so command-issuing kernels get WASM and the fast dispatch path. Measured at 18% on emit for a large change while the sync point dominates, so it waits for a faster sync point. +- **Per-archetype query-match bitsets.** Compute stop/start for each move as `match(from) & ~match(to)` in O(Q/32). Memory scales with archetypes, not archetype-graph edges. +- **Per-archetype rule-closure cache.** +- **Main-thread fast path.** Main-thread structural changes may skip the command buffer, or be applied early, only when no system or finalizer could observe the difference before the next sync point. +- **Bulk operations in the plan.** Keep (archetype, all rows) or entity-ID ranges, not one entry per entity, so million-entity despawns stay cheap. Despawning a pair target bulk-moves by (source archetype, target). +- **Finalizer execution:** skip the phase when nothing is pending. Run on the main thread by default; move to workers only above a size threshold and only for linear-memory finalizers. Run main-thread and worker finalizers concurrently. That's at most one barrier per flush. +- **Gather loops.** The compiler needs an indexed-row loop mode for scattered sets, such as rows about to be removed. +- **Sparse dirty lists.** A possible option for very large columns that change rarely. +- **Archetype churn.** Derived components that toggle on and off move entities between tables. Candidates: sparse-set storage, or modeling the condition as a value. +- **GPU residency, next steps.** + - u32 tick columns on the GPU, relative to an epoch rebased at sync points, so GPU kernels can stamp rows and use change filters. + - Apply the structural plan on the GPU as row-copy lists run by a move kernel, instead of reading back first. + - GPU kernels with change filters, lookups and loops. + - One dispatch over several archetypes, and indirect draws; heap segments past `maxBufferSize`; automatic placement. + - Finalizers on components ahead on the GPU: until then, the sync point reads them back first. + - Read back only the rows a sync point moves, rather than whole disturbed archetypes, or move them on the GPU. +- **GPU-only components, not planned until a workload needs one.** A const meta tag on the component entity (components are entities), so its values have a column in the GPU heap and none in the CPU arena; entities, archetypes and `has`/`not` stay on the CPU. Values given at spawn are uploaded once; otherwise rows start at the schema default. + - **The use case:** GPU state with history that nothing on the CPU reads, such as a simulation integrated only on the GPU, previous-frame transforms for motion vectors, or accumulators. Data recomputed every frame (culled lists, indirect draw arguments, sort keys) isn't state: render systems own those as scratch buffers. Cached derived data (world matrices, skinning palettes) is an optimization, not a need. + - **What it adds over an ordinary component written only on the GPU,** which already stays GPU-resident and is never read back unless observed: the CPU column's memory, and a type-level guarantee that CPU systems and observers can't name it. + - **What it requires:** structural changes on the GPU (the move kernel above), since there's no CPU copy to move. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md new file mode 100644 index 000000000..566e8c1e6 --- /dev/null +++ b/packages/ecs/followups.md @@ -0,0 +1,146 @@ +# ecs follow-ups + +Open items awaiting a decision or work. Remove an item once it's decided or done. + +## Queued (decided, not started) + + +## Decisions for the maintainers + +- **An index lookup's `changed: true` is a change filter,** as a resource in `changed` is: when the index changed every + row passes, and otherwise rows pass by the system's other change filters, so with `components.changed` it runs the + rows that changed even while the index didn't. Only a system whose change filters are all such indexes, with no + `up`, is skipped before its body, keeping its `lastRun`. The alternative, a condition, would run no row while the + index is unchanged, whatever else changed. Recommended: accept. + +- **Re-adding an observer system:** a re-added observer system runs from the next pass, even for a run already queued, + while a first add waits for registration. Recommended: accept. +- **Indexes without ids on joined threads:** these now work instead of throwing; the documentation only required ids on + keys. Recommended: accept. +- **Factories after dispose:** the factories give no first value. Recommended: accept. +- **Factory failure labels are numbered.** Recommended: accept. +- **Factory read gaps:** + - a cold resource read costs 0.875 µs against 0.54–0.58 µs before; + - "1K entities, one changed" costs 29.7 µs against 25.8 µs. + + Recommended: accept both, since single-entity observing is for debugging. +- **The cadence benchmarks report the mean of the middle half of ticks** rather than the median, because chromium's + timer steps are about one tick long. +- **Filtering in the todo sample:** it writes `hidden` on each row (about 2 ms of script at 10K) rather than setting one + attribute on the list and hiding rows with CSS. The attribute version is slower once laid out (25 ms against 17 ms + at 10K) and puts the filter's meaning in the list element. +- **One-off reads:** `world.run` returns no value, so a one-off read needs an observer system that writes into an outer + variable. +- **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a + per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image + per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU + row order. Not needed until a consumer's profile says so. +- **Paint order across render tables:** a render system draws each matching archetype as its own table in row order, + so 2D content of several kinds (shapes, images, paths) can't be blended back to front without the consumer keeping + its own sorted instance list. Proposal: `order: index` on `System.render`, giving one table spanning its archetypes + whose `bind` sets a buffer of the index's rows in sorted order with each archetype's column base addresses; every + column already lives in one heap, so accessors resolve a sorted instance to its row. Not needed while consumers draw + every kind with one shader from their own list. +- **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal + constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then + a world option or resource. +- **The cost of a report for every run:** a clean run costs about 125 ns more in node (`run idle` 333 to 458 ns, `run + observer` 166 to 291), half of it the two clock reads `elapsed` takes. Decided: timing stays on. Check again if runs + get cheap enough for it to matter: a `Report.timing` resource, off by default with `elapsed` NaN, measured `run idle` + 416 ns and `run observer` 208. +- **Reports are hidden from queries that require no component,** as the resource entity is: an index or system with no + `has` or `read`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the + world's own. +- **Report components are writable:** systems may write or despawn reports, which are ordinary entities; a despawned + one is spawned again for a later run. Recommended: accept, as with any entity; or make them world-written as `clock` + is. +- **Errors while tails run:** an add or removal of `Report.errors` found after a run's sync point waits for the next + sync point while tails run on a joined thread, an earlier run's errors emptied meanwhile. Recommended: accept; errors + known before the sync point, system failures, skipped systems and warnings, are in place when the run resolves. +- **A joined thread's own observer runs:** what such a run's body throws reaches the world for its next report rather + than the run's, and a `run` of several of its own observer systems resolves to the last one's number. Recommended: + accept. +- **The factories' own failures** are rethrown out of band from the reports their runs see, a pass's failures once a + later run of theirs sees the next report; on a joined thread they're only in the world's reports. Recommended: + accept, since the factories are for tests and debugging. +- **An observer pass's failures are in the next run's report,** not their own run's, so each report's errors are + written once; nothing reports them until another run or tick. Decided. +- **Strings live in shared memory and have no thread:** any string, a union of string literals, and either with + `null`, which is handle 0. A `thread` on one is rejected in types and at registration. Recommended: accept. +- **Records only while a thread may read them:** the world's thread holds string values as objects, and keeps records + from the first `share` until the last thread is gone, so a world with no joined thread pays nothing for them (`string + read 10K` 2.78 ns a row, as `object read 10K`; `string write 10K` 19.5 ns against 21). Recommended: accept. +- **Writing strings with a joined thread costs a record each:** `string write 10K joined` 127 ns a row, most of it + flattening the new string to copy its code units; `TextEncoder.encodeInto` and copies through scratch measured the + same. Recommended: accept; revisit with writes encoded lazily, at the next point a joined thread could read them. +- **Only the world's thread writes strings:** a system placed on a joined thread that writes one is rejected, and a + command there gives one only its start. Recommended: accept for now; a joined thread's writes would need records + allocated there, through the arena's top in shared memory. +- **Kernels don't read strings yet,** by type and check, and **an index keyed by strings is looked up only on the + world's thread,** which keeps it by values. Recommended: accept for now; kernels would need the records' code units + compared in the compiler, and joined lookups an index of interned records. +- **An observer system on a joined thread that reads a string resource doesn't post copies of its resources:** nothing + waits for such posts, and a handle there could outlive its record, so it posts as row systems do. Recommended: accept. + +## Findings from the todo sample (`src/samples/todo/README.md`) + +2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities + don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over + the entity a reference names. +4. **Low-cardinality hash indexes cost O(bucket) per change.** Buckets are kept in entity order and shifted on removal: + one toggle costs 26, 62 and 400 µs at 1K, 10K and 100K. Proposal: unordered buckets with swap-remove and a position + map, or a count-only index. +5. **Change filters scan the whole dirty chunk** (1,024 row ticks), once per system that filters on `changed`. + Proposal: a second level of change ticks, one per 32–64 rows. +6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in + `src/observe`. + +## Findings from strings in shared memory + +- **A joined finalizer read a resource of its thread as undefined:** in `remote-finalizers.test.ts`, "that throw are in + a later run's report", the finalizer's `resources[tone]`, an object of its thread, is undefined for the stops of a + sync point soon after the thread joined, where the same finalizer reads it in the other cases; the fixture reads it + optionally now. The test checks no tone, so whether it was undefined before, as a string, wasn't seen. Worth finding + out whether a remote finalizer can run before its thread's resources reach its mirror. + +## Features to discuss + +- **Computed index keys:** an index keyed on a value computed from a component, such as a struct field + (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms: + `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions: + - how key words are laid out in shared memory; + - lookups on workers, on joined threads and in kernels (JS and WASM); + - change tracking that follows the field, not the whole component; + - whether a function form can run anywhere but the world's thread. +- **Discriminated struct unions:** unions of structs with a number, boolean or string-literal discriminant, stored in + fixed size (a tag column plus the variants' fields) so they live in shared memory. Today `isStruct` + (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers, + narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level + `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't. + +## Performance + +- **The filtered `up` benchmarks are slower than this machine's record at `e3c30aa1`:** `up one root changed` +49%, + `up filtered after a reparent` +45%, `up small tree in a big world` +79%. They're the same at `c6bb3c83`, before + strings, so the drift is older. Worth bisecting from `e3c30aa1`. + +- **A joined thread's first tail task once never finished,** under heavy machine load (two test suites at once): the + world waited on `channel.idle()` with task 1 posted and not done (`threads.browser.test.ts`, tails "see one tick's + values"). It didn't recur in 25 runs without the extra load, and the base failed once on another threads test in the + same comparison. Worth a stress run of the joined-thread suites under load. + +- **`transfer`, chromium:** its median varied with machine load. Check it alone on a quiet machine before the next + record. +- **`joined observer 10K`:** checking archetype versions once per post instead of per row would save some of the + thread's run (see perf.md "Next"). + +## Earlier open work + +- Index step 5 items. +- Starting the pool for large index builds. +- `order` on workers. +- Typed transfers (Phase 7). +- The `Finalizer` namespace. +- Chromium timer clamps. +- The `transfer` cost. +- The perf.md "Next" items. diff --git a/packages/ecs/memory.md b/packages/ecs/memory.md new file mode 100644 index 000000000..be03816b5 --- /dev/null +++ b/packages/ecs/memory.md @@ -0,0 +1,56 @@ +# ecs memory + +What the maintainers have decided about working on this package. These aren't derivable from the code. Add new +decisions about ecs here rather than in a personal memory, so every machine and developer has them. + +## Interface + +- **Humans own the interface, agents own the implementation** (`src/interface/CLAUDE.md`). Agents add or widen nothing + public: no exported types, built-in components or resources, fields, options, union members or observable behaviour. + Any change that can't be avoided is proposed for review before it lands. +- **Export a type only if a consumer would declare something with it.** Check the guide and samples; if they never name + it, consumers infer it with `Parameters<>`, `ReturnType<>` or indexed access. Agents may remove such exports and say + what they removed. +- **Report every contract change explicitly,** separately from implementation notes, including small ones: a new + optional field, a removed helper, a change in errors, timing or read semantics. +- **Narrow the type, not the value.** Hiding an implementation detail narrows only the public type; the runtime value + keeps its fields. Internal code recovers them with a runtime type guard, not casts or side tables. Never + `Object.freeze` or `Object.seal`. +- **Interface first:** type changes with type tests shown red then green, then the runtime with runtime tests shown red + then green, then docs. + +## Performance + +- **Choose by performance, not by effort or risk.** Compare options on big-O and measured results for common + workloads. No estimates in days or weeks, and no ranking by implementation risk: the ECS has no clients yet and + behaviour and performance tests catch regressions. +- **Every mechanism needs a benchmark proving its benefit,** or it's removed. +- **Record every dropped experiment** in `src/implementation/world/perf.md` under "Tried and not kept", with the + measured result, why it didn't pay off, and what would make it worth revisiting, before reporting the drop. + +## Threads and the loop + +- **Joined threads, `main` above all, yield to their event loop at least once per tick** and never run synchronous work + unbounded. +- **The default loop ticks once per `requestAnimationFrame`.** + +## Direction + +- **Reading and observing go through systems.** Consumers write plain observer systems, as the DOM work will; the + `Observe` pattern isn't offered to them, and a world has no observers. Observer factories built on observer + systems with only the public API stay internal (`src/observe`), for tests, debugging and benchmarks, never exported. +- **Observing single entities is mainly for debugging.** Production reads, such as DOM bindings, are ordinary row + systems over every matching row, which always beat one system per entity. Spend optimization effort on row systems + and queries, and keep the entity factory correct and reasonably efficient. +- **One-off systems created at runtime aren't a large source of expected runtime;** regressions confined to them are + acceptable. + +## Checks + +- **The ECS is independent of the rest of the monorepo:** check only this package. Before a push, delete this package's + `*.tsbuildinfo`, then run `pnpm run build` (it is `tsc -b`, so it typechecks too) and `pnpm test` here. Never rebuild + or test other packages or run `pnpm -r` for ECS work. + +## Follow-ups + +- **Open decisions and findings are tracked in `followups.md`.** Add new ones there, and remove an item once it's decided or done. diff --git a/packages/ecs/package.json b/packages/ecs/package.json new file mode 100644 index 000000000..bd8b4dd5b --- /dev/null +++ b/packages/ecs/package.json @@ -0,0 +1,69 @@ +{ + "name": "@adobe/ecs", + "version": "0.1.0", + "description": "Adobe ECS", + "type": "module", + "sideEffects": false, + "private": false, + "independentVersion": true, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "files": [ + "dist", + "!dist/**/*.test.*", + "!dist/**/*.type-test.*", + "!dist/**/*.test-helper.*", + "!dist/**/*.cases.*", + "!dist/**/*.fixture.*", + "!dist/fixtures", + "!dist/guide", + "!dist/perf", + "!dist/perftest", + "!dist/samples", + "!dist/observe", + "!dist/**/*.tsbuildinfo", + "src", + "LICENSE" + ], + "scripts": { + "build": "cp ../../LICENSE . && tsc -b", + "dev": "tsc -b -w --preserveWatchOutput", + "typecheck": "tsc -b", + "test": "pnpm exec playwright install chromium && vitest --run", + "test:perf": "pnpm exec playwright install chromium && vitest --run --config vitest.perf.config.ts", + "perf": "node scripts/perf.mjs", + "perf:browser": "node scripts/perf.mjs --browser", + "perf:report": "node scripts/perf.mjs --report", + "sample": "vite --config vite.sample.config.ts" + }, + "devDependencies": { + "@types/estree": "^1.0.5", + "@types/node": "^25.6.0", + "@vitest/browser-playwright": "^5.0.5", + "@webgpu/types": "^0.1.61", + "playwright": "^1.64.1", + "typescript": "^5.8.3", + "vite": "^8.3.4", + "vitest": "^5.0.5" + }, + "peerDependencies": { + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./compiler": { + "import": "./dist/implementation/compiler/index.js", + "types": "./dist/implementation/compiler/index.d.ts" + } + } +} diff --git a/packages/ecs/perf/README.md b/packages/ecs/perf/README.md new file mode 100644 index 000000000..d22558a37 --- /dev/null +++ b/packages/ecs/perf/README.md @@ -0,0 +1,1309 @@ +# ecs performance + +Generated by `pnpm perf:report` from `history/`; don't edit it by hand. + +- `pnpm perf [filter]` runs the node benchmarks, bundled as in production, and compares each with this machine's last record. `pnpm perf:browser` runs the chromium ones: main thread, workers and GPU. +- Add `--record` to append the results to `history/.jsonl` and regenerate this file. Record on an idle machine, and commit the record with the change it measures. +- **ns per N** is the fastest run's time divided by N. **Best** is the time to move the fewest bytes an ideal implementation must, in this ECS's storage model, at the fastest copy bandwidth the run measured, with data in the caches; **× best** is how far from it the run is, and **Room** the milliseconds between them. **Change** compares with the previous record on the same machine; beyond ±10% it says slower or faster. + +## Apple M1 Max, 10 cores, 32 GB, darwin arm64 + +### Chrome 156.0.0.0 + +Latest `e3c30aa1+`, 2026-10-20. Copy bandwidth in GB/s by working set: +- One thread: 119 at 512 KB, 112 at 2 MB, 70.3 at 8 MB, 89.5 at 32 MB, 95.9 at 128 MB. +- All workers: 746 at 512 KB, 245 at 2 MB, 240 at 8 MB, 239 at 32 MB, 213 at 128 MB. +- GPU: 353. + +| Benchmark | Per | N | Typical N | ms | ns per N | Best ns per N | × best | Room, ms | Change | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| move main | matched rows | 250K | 1K–1M | 0.085 | 0.34 | 0.302 | 1.13× | 0.00956 | −0% | +| follow main | rows | 1M | 1K–100K | 3.42 | 3.42 | 0.436 | 7.85× | 2.98 | −2% | +| chain main | row passes | 6M | 6K–6M | 1.56 | 0.26 | 0.0335 | 7.75× | 1.36 | +1% | +| sparse changed main | rows in the archetype | 1M | 10K–1M | 0.3 | 0.3 | 0.0691 | 4.34× | 0.231 | +2% | +| up depth 10 main | rows | 1M | 1K–100K | 2.03 | 2.03 | 0.235 | 8.65× | 1.8 | +0% | +| up depth 1000 main | rows | 1M | 1K–100K | 2.34 | 2.34 | 0.235 | 9.99× | 2.11 | −1% | +| move workers | matched rows | 250K | 1K–1M | 0.015 | 0.06 | 0.0483 | 1.24× | 0.00293 | +0% | +| follow workers | rows | 1M | 1K–100K | 0.525 | 0.525 | 0.0697 | 7.53× | 0.455 | +1% | +| chain workers | row passes | 6M | 6K–6M | 0.24 | 0.04 | 0.00536 | 7.46× | 0.208 | +2% | +| sparse changed workers | rows in the archetype | 1M | 10K–1M | 0.065 | 0.065 | 0.0111 | 5.88× | 0.0539 | +18% slower | +| up depth 10 workers | rows | 1M | 1K–100K | 0.345 | 0.345 | 0.0376 | 9.19× | 0.307 | +6% | +| up depth 1000 workers | rows | 1M | 1K–100K | 2.38 | 2.38 | 0.0376 | 63.2× | 2.34 | +0% | +| up depth 10 gpu | rows | 1M | 1K–100K | 0.234 | 0.234 | 0.0453 | 5.17× | 0.189 | −2% | +| up depth 1000 gpu | rows | 1M | 1K–100K | 4.35 | 4.35 | 0.0453 | 96.2× | 4.31 | +2% | +| spawn bulk kernel main | spawns | 100K | 100–100K | 1.03 | 10.3 | 0.235 | 44.1× | 1.01 | +0% | +| spawn bulk kernel workers | spawns | 100K | 100–100K | 0.7 | 7 | 0.0376 | 186× | 0.696 | +5% | +| loop tick idle | ticks | 10K | 1–1 | 42.8 | 4,285 | 0.536 | 7,987× | 42.8 | −1% | +| sync point grow 100 archetypes main | archetypes grown | 100 | 1–100 | 0.075 | 750 | 0.536 | 1,398× | 0.0749 | +7% | +| sync point grow 100 archetypes workers | archetypes grown | 100 | 1–100 | 0.605 | 6,050 | 0.0858 | 70,488× | 0.605 | −1% | +| wake | round trips | 10K | 1–100 | 8.48 | 848 | 1.07 | 790× | 8.46 | +12% slower | +| emit main | rows | 1M | 1K–100K | 9.18 | 9.18 | 0.285 | 32.2× | 8.9 | +1% | +| emit workers | rows | 1M | 1K–100K | 7.72 | 7.72 | 0.0456 | 169× | 7.68 | +1% | +| remote run | runs | 1K | 1–100 | 14.6 | 14,560 | 1.61 | 9,047× | 14.6 | −3% | +| mailbox resource | ticks | 1K | 1–1 | 6.46 | 6,460 | 0.529 | 12,205× | 6.46 | +5% | +| mailbox select 10K | ticks | 1K | 1–1 | 728 | 727,685 | 1,985 | 367× | 726 | −0% | +| log replay | moves | 10K | 1–10K | 0.32 | 32 | 1.07 | 29.8× | 0.309 | +5% | +| log rebuild 100K | rows | 100K | 1K–100K | 0.29 | 2.9 | 0.503 | 5.77× | 0.24 | −19% faster | +| handoff | ticks | 1K | 1–1 | 14.1 | 14,085 | 10.7 | 1,313× | 14.1 | +3% | +| fused tiers | ticks | 1K | 1–1 | 9.87 | 9,870 | 2.15 | 4,600× | 9.87 | −1% | +| despawn objects 10K | despawns | 10K | 1–10K | 1.05 | 105 | 0.469 | 223× | 1.04 | −0% | +| tail prompt | ticks | 1K | 1–1 | 22.8 | 22,825 | 103 | 222× | 22.7 | −2% | +| tail late snapshot 10K | rows copied | 10K | 100–10K | 0.05 | 5 | 1.44 | 3.47× | 0.0356 | +11% slower | +| tail overlap | ticks | 100 | 1–1 | 260 | 2,600,250 | 12,740 | 204× | 259 | +0% | +| tail overlap waiting | ticks | 100 | 1–1 | 474 | 4,742,500 | 6,370 | 744× | 474 | +1% | +| transfer | transfers | 100 | 1–100 | 20 | 200,050 | 2.15 | 93,230× | 20 | +74% slower | +| index rebuild 1M workers | entities | 1M | 10K–1M | 6.92 | 6.92 | 0.0322 | 215× | 6.89 | +8% | +| grid neighbors 100K workers | rows | 100K | 1K–100K | 17.6 | 176 | 1.34 | 131× | 17.5 | +1% | +| joined observer resource | ticks | 1K | 1–1 | 6.44 | 6,440 | 0.536 | 12,005× | 6.44 | | +| joined observer 10K | ticks | 1K | 1–1 | 357 | 357,185 | 2,012 | 178× | 355 | | +| joined resource factory | ticks | 1K | 1–1 | 6.84 | 6,835 | 0.536 | 12,741× | 6.83 | | +| joined select 10K factory | ticks | 1K | 1–1 | 361 | 360,710 | 2,012 | 179× | 359 | | +| joined resource factory cadence | ticks | 100 | 1–1 | 6.6 | 66,040 | 0.536 | 123,107× | 6.6 | | +| observe gpu entity factory | runs | 1 | 1–1 | 0.393 | 392,500 | 1,132 | 347× | 0.391 | | + +History, ns per N, oldest first: + +| Benchmark | `3fdcd402` 10-18 | `eb4a7301+` 10-18 | `3c43e8ac` 10-18 | `dcec1e13+` 10-18 | `feecbf80+` 10-19 | `cfb4b7ec+` 10-19 | `c20ed043+` 10-19 | `e3c30aa1+` 10-20 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| move main | 0.34 | 0.34 | 0.34 | 0.34 | 0.34 | 0.34 | 0.34 | 0.34 | +| follow main | 3.48 | 3.5 | 3.49 | 3.49 | 3.44 | 3.44 | 3.48 | 3.42 | +| chain main | 0.258 | 0.257 | 0.259 | 0.257 | 0.255 | 0.258 | 0.258 | 0.26 | +| sparse changed main | 0.3 | 0.295 | 0.295 | 0.29 | 0.29 | 0.285 | 0.295 | 0.3 | +| up depth 10 main | 2.04 | 2.04 | 2.08 | 2.08 | 2.04 | 2.05 | 2.03 | 2.03 | +| up depth 1000 main | 2.36 | 2.39 | 2.39 | 2.43 | 2.34 | 2.37 | 2.36 | 2.34 | +| move workers | 0.06 | 0.06 | 0.06 | 0.06 | 0.06 | 0.06 | 0.06 | 0.06 | +| follow workers | 0.52 | 0.52 | 0.52 | 0.52 | 0.52 | 0.52 | 0.52 | 0.525 | +| chain workers | 0.0392 | 0.0392 | 0.0392 | 0.0392 | 0.0392 | 0.0392 | 0.0392 | 0.04 | +| sparse changed workers | 0.055 | 0.06 | 0.05 | 0.055 | 0.055 | 0.055 | 0.055 | 0.065 | +| up depth 10 workers | 0.34 | 0.33 | 0.315 | 0.335 | 0.33 | 0.34 | 0.325 | 0.345 | +| up depth 1000 workers | 2.39 | 2.4 | 2.38 | 2.45 | 2.38 | 2.37 | 2.37 | 2.38 | +| up depth 10 gpu | 0.236 | 0.232 | 0.235 | 0.24 | 0.239 | 0.234 | 0.238 | 0.234 | +| up depth 1000 gpu | 4.26 | 4.56 | 4.54 | 4.61 | 4.3 | 4.27 | 4.28 | 4.35 | +| spawn bulk kernel main | 10.4 | 10.6 | 10.6 | 10.6 | 10.2 | 10.3 | 10.3 | 10.3 | +| spawn bulk kernel workers | 6.65 | 6.95 | 7.1 | 7.2 | 6.6 | 6.9 | 6.65 | 7 | +| loop tick idle | 4,314 | 4,470 | 4,238 | 4,438 | 4,363 | 4,444 | 4,344 | 4,285 | +| sync point grow 100 archetypes main | 700 | 800 | 750 | 750 | 750 | 750 | 700 | 750 | +| sync point grow 100 archetypes workers | 5,950 | 6,150 | 5,900 | 6,150 | 5,950 | 6,450 | 6,100 | 6,050 | +| wake | 739 | 744 | 749 | 792 | 730 | 741 | 753 | 848 | +| emit main | 9.3 | 9.31 | 9.34 | 9.31 | 9.27 | 9.07 | 9.11 | 9.18 | +| emit workers | 7.69 | 7.82 | 7.79 | 7.88 | 7.65 | 7.61 | 7.68 | 7.72 | +| remote run | 14,755 | 15,590 | 15,230 | 16,275 | 14,440 | 14,955 | 15,085 | 14,560 | +| mailbox resource | 6,135 | 6,015 | 6,320 | 6,355 | 6,025 | 6,130 | 6,460 | | +| mailbox select 10K | 720,275 | 746,005 | 739,710 | 743,480 | 724,840 | 727,840 | 727,685 | | +| log replay | 32.5 | 33 | 30.5 | 30.5 | 34.5 | 34 | 30.5 | 32 | +| log rebuild 100K | 3.35 | 3.55 | 3.5 | 3.5 | 3.55 | 3.65 | 3.6 | 2.9 | +| handoff | 11,440 | 13,705 | 13,730 | 14,700 | 13,370 | 13,695 | 13,680 | 14,085 | +| fused tiers | 8,635 | 9,300 | 9,915 | 9,890 | 9,385 | 9,090 | 9,925 | 9,870 | +| despawn objects 10K | 106 | 104 | 102 | 101 | 105 | 108 | 105 | 105 | +| tail prompt | 18,755 | 22,845 | 24,880 | 24,925 | 23,485 | 23,730 | 23,210 | 22,825 | +| tail late snapshot 10K | 5 | 6 | 7 | 6.5 | 5 | 5 | 4.5 | 5 | +| tail overlap | | | 2,531,050 | 2,581,450 | 2,472,500 | 2,478,750 | 2,590,500 | 2,600,250 | +| tail overlap waiting | | | 4,818,800 | 4,841,500 | 4,628,500 | 4,647,850 | 4,711,750 | 4,742,500 | +| transfer | | | | 134,100 | 137,900 | 144,300 | 115,050 | 200,050 | +| index rebuild 1M workers | | | | | | 7.24 | 6.42 | 6.92 | +| grid neighbors 100K workers | | | | | | 174 | 174 | 176 | +| joined observer resource | | | | | | | | 6,440 | +| joined observer 10K | | | | | | | | 357,185 | +| joined resource factory | | | | | | | | 6,835 | +| joined select 10K factory | | | | | | | | 360,710 | +| joined resource factory cadence | | | | | | | | 66,040 | +| observe gpu entity factory | | | | | | | | 392,500 | + +### node 24.15.0 + +Latest `e3c30aa1+`, 2026-10-20. Copy bandwidth in GB/s by working set: +- One thread: 124 at 512 KB, 124 at 2 MB, 85.3 at 8 MB, 73.8 at 32 MB, 96.6 at 128 MB. +- All workers: 670 at 512 KB, 245 at 2 MB, 238 at 8 MB, 232 at 32 MB, 203 at 128 MB. + +| Benchmark | Per | N | Typical N | ms | ns per N | Best ns per N | × best | Room, ms | Change | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| create | spawns | 1M | 100–100K | 58.4 | 58.4 | 0.386 | 151× | 58 | +0% | +| create flat | spawns | 1M | 100–100K | 66 | 66 | 0.386 | 171× | 65.6 | +2% | +| create flat dynamic | spawns | 1M | 100–100K | 92.3 | 92.3 | 0.386 | 239× | 91.9 | −2% | +| move | matched rows | 250K | 1K–1M | 0.0898 | 0.359 | 0.289 | 1.24× | 0.0174 | −1% | +| churn | rows visited | 1M | 1K–100K | 15.1 | 15.1 | 0.233 | 64.7× | 14.8 | −3% | +| retag | rows visited | 1M | 1K–100K | 32.3 | 32.3 | 0.314 | 103× | 32 | −0% | +| sparse changed | rows in the archetype | 1M | 10K–1M | 0.318 | 0.318 | 0.0662 | 4.8× | 0.252 | −1% | +| sparse changed clustered | rows in the archetype | 1M | 10K–1M | 0.0222 | 0.0222 | 0.00265 | 8.39× | 0.0196 | −1% | +| follow | rows | 1M | 1K–100K | 3.82 | 3.82 | 0.418 | 9.14× | 3.4 | −0% | +| emit | rows | 1M | 1K–100K | 8.77 | 8.77 | 0.273 | 32.1× | 8.49 | +1% | +| reduce | rows | 1M | 1K–1M | 0.504 | 0.504 | 0.0322 | 15.7× | 0.471 | −0% | +| chain | row passes | 6M | 6K–6M | 1.56 | 0.259 | 0.0322 | 8.07× | 1.36 | +1% | +| up depth 10 | rows | 1M | 1K–100K | 2.02 | 2.02 | 0.225 | 8.98× | 1.8 | −1% | +| up depth 1000 | rows | 1M | 1K–100K | 2.39 | 2.39 | 0.225 | 10.6× | 2.16 | −0% | +| up one root changed | rows | 1M | 1K–100K | 0.0244 | 0.0244 | 0.0000628 | 389× | 0.0244 | +19% slower | +| up after a spawn | rows | 1M | 1K–100K | 2.09 | 2.09 | 0.225 | 9.29× | 1.87 | +1% | +| up filtered after churn | rows | 1M | 1K–100K | 0.0503 | 0.0503 | 0.00011 | 457× | 0.0502 | +28% slower | +| up filtered after tagged churn | rows | 1M | 1K–100K | 0.0413 | 0.0413 | 0.00011 | 376× | 0.0412 | +11% slower | +| up filtered after a reparent | rows | 1M | 1K–100K | 0.0196 | 0.0196 | 0.0000628 | 312× | 0.0196 | −3% | +| up load | spawns | 1M | 1K–100K | 215 | 215 | 0.547 | 393× | 214 | +2% | +| up small tree in a big world | tree rows | 1K | 100–10K | 0.0106 | 10.6 | 0.225 | 47× | 0.0104 | −7% | +| main thread | row reads | 50K | 1K–100K | 0.328 | 6.56 | 0.0965 | 68× | 0.323 | −0% | +| select | rows | 1M | 100–100K | 8.16 | 8.16 | 0.0655 | 124× | 8.09 | +0% | +| move workers | matched rows | 250K | 1K–1M | 0.0199 | 0.0795 | 0.0538 | 1.48× | 0.00644 | +2% | +| follow workers | rows | 1M | 1K–100K | 0.577 | 0.577 | 0.0776 | 7.44× | 0.5 | −1% | +| run idle | runs | 1 | 1–1 | 0.000333 | 333 | 0.514 | 647× | 0.000332 | −20% faster | +| run 50 idle | systems | 50 | 10–200 | 0.00354 | 70.8 | 0.514 | 138× | 0.00352 | −37% faster | +| sync point one spawn | runs | 1 | 1–1 | 0.000833 | 833 | 1.03 | 810× | 0.000832 | −9% | +| move 1K | matched rows | 1K | 1K–100K | 0.000666 | 0.666 | 0.289 | 2.3× | 0.000377 | −6% | +| move 10K | matched rows | 10K | 1K–100K | 0.00421 | 0.421 | 0.289 | 1.45× | 0.00131 | −3% | +| main thread 1K | row reads | 1K | 1K–100K | 0.00658 | 6.58 | 0.0965 | 68.3× | 0.00649 | −1% | +| main thread 10K | row reads | 10K | 1K–100K | 0.0626 | 6.26 | 0.0965 | 64.9× | 0.0617 | +0% | +| up depth 10 10K | rows | 10K | 1K–100K | 0.0177 | 1.77 | 0.225 | 7.85× | 0.0154 | −1% | +| spawn bulk | spawns | 100K | 100–100K | 2.52 | 25.2 | 0.193 | 130× | 2.5 | −2% | +| spawn bulk kernel | spawns | 100K | 100–100K | 1.05 | 10.5 | 0.225 | 46.6× | 1.03 | +0% | +| spawn singles | spawns | 4 | 1–10 | 0.00108 | 271 | 0.322 | 842× | 0.00108 | −7% | +| spawn sixteen archetypes | spawns | 100K | 100–100K | 4 | 40 | 0.193 | 207× | 3.98 | −2% | +| despawn bulk | despawns | 100K | 100–100K | 4.41 | 44.1 | 0.129 | 343× | 4.39 | −3% | +| despawn bulk kernel | despawns | 100K | 100–100K | 4.4 | 44 | 0.161 | 274× | 4.38 | −3% | +| despawn one | despawns | 1 | 1–10 | 0.0015 | 1,500 | 0.772 | 1,944× | 0.0015 | −5% | +| add tag | entities moved | 100K | 100–100K | 9.54 | 95.4 | 0.129 | 742× | 9.53 | −1% | +| add tag kernel | entities moved | 100K | 100–100K | 8.29 | 82.9 | 0.161 | 516× | 8.28 | −3% | +| add tag from four archetypes | entities moved | 100K | 100–100K | 9.59 | 95.9 | 0.129 | 746× | 9.58 | −3% | +| add value | entities moved | 100K | 100–100K | 9.55 | 95.5 | 0.161 | 594× | 9.53 | −4% | +| add several | entities moved | 100K | 100–100K | 12.9 | 129 | 0.193 | 670× | 12.9 | −1% | +| remove tag | entities moved | 100K | 100–100K | 7.01 | 70.1 | 0.129 | 545× | 7 | −2% | +| remove tag kernel | entities moved | 100K | 100–100K | 5.9 | 59 | 0.161 | 367× | 5.88 | −0% | +| remove tag from four archetypes | entities moved | 100K | 100–100K | 7.78 | 77.8 | 0.129 | 605× | 7.76 | +0% | +| remove several | entities moved | 100K | 100–100K | 7.79 | 77.9 | 0.129 | 606× | 7.78 | −1% | +| add replace | entities | 100K | 100–100K | 7.45 | 74.5 | 0.0965 | 772× | 7.44 | −4% | +| spawn bulk kernel workers | spawns | 100K | 100–100K | 0.697 | 6.97 | 0.0418 | 167× | 0.693 | +1% | +| loop tick idle | ticks | 10K | 1–1 | 19.5 | 1,948 | 0.514 | 3,786× | 19.5 | +3% | +| sync point grow 100 archetypes workers | archetypes grown | 100 | 1–100 | 0.778 | 7,781 | 0.0956 | 81,423× | 0.778 | −6% | +| wake | round trips | 10K | 1–100 | 7.81 | 781 | 1.03 | 759× | 7.8 | +11% slower | +| emit main | rows | 1M | 1K–100K | 8.68 | 8.68 | 0.273 | 31.8× | 8.4 | −0% | +| emit workers | rows | 1M | 1K–100K | 6.47 | 6.47 | 0.0508 | 127× | 6.42 | +5% | +| remote run | runs | 1K | 1–100 | 10.1 | 10,089 | 1.54 | 6,537× | 10.1 | +2% | +| mailbox resource | ticks | 1K | 1–1 | 5.16 | 5,161 | 0.524 | 9,843× | 5.16 | −1% | +| mailbox select 10K | ticks | 1K | 1–1 | 750 | 749,652 | 1,966 | 381× | 748 | −1% | +| log replay | moves | 10K | 1–10K | 0.203 | 20.3 | 1.03 | 19.8× | 0.193 | −0% | +| log rebuild 100K | rows | 100K | 1K–100K | 0.332 | 3.32 | 0.482 | 6.88× | 0.284 | +10% | +| handoff | ticks | 1K | 1–1 | 9.98 | 9,979 | 10.3 | 970× | 9.97 | −5% | +| fused tiers | ticks | 1K | 1–1 | 6.57 | 6,566 | 2.06 | 3,191× | 6.56 | −3% | +| despawn objects 10K | despawns | 10K | 1–10K | 0.803 | 80.3 | 0.45 | 178× | 0.798 | −4% | +| tail prompt | ticks | 1K | 1–1 | 20.3 | 20,324 | 98.5 | 206× | 20.2 | +4% | +| tail late snapshot 10K | rows copied | 10K | 100–10K | 0.0824 | 8.24 | 1.38 | 5.96× | 0.0686 | +18% slower | +| tail overlap | ticks | 100 | 1–1 | 244 | 2,440,215 | 12,219 | 200× | 243 | −0% | +| tail overlap waiting | ticks | 100 | 1–1 | 469 | 4,686,760 | 6,109 | 767× | 468 | +2% | +| read observe | reads | 1 | 1–100 | 0.000542 | 542 | 0.524 | 1,034× | 0.000541 | +0% | +| read run | reads | 1 | 1–100 | 0.0113 | 11,333 | 0.514 | 22,029× | 0.0113 | +11% slower | +| read run changed | reads | 1 | 1–100 | 0.0122 | 12,166 | 0.514 | 23,648× | 0.0122 | +2% | +| observe 1K entities | observers | 1K | 10–1K | 0.024 | 24 | 0.131 | 183× | 0.0239 | +0% | +| observe 1K entities one changed | observers | 1K | 10–1K | 0.0251 | 25.1 | 0.131 | 191× | 0.025 | +1% | +| select page 100K | rows | 100K | 1K–100K | 1.12 | 11.2 | 0.131 | 85.6× | 1.11 | −2% | +| loop tick add remove | ticks | 10K | 1–1 | 244 | 24,368 | 0.514 | 47,366× | 244 | −10% faster | +| loop tick add remove again | ticks | 10K | 1–1 | 207 | 20,658 | 0.514 | 40,154× | 207 | −0% | +| loop tick idle after add remove | ticks | 10K | 1–1 | 17.8 | 1,778 | 0.514 | 3,455× | 17.8 | −11% faster | +| index idle | indexes | 1K | 1–100 | 0.00925 | 9.25 | 0.129 | 71.9× | 0.00912 | −1% | +| index refresh 1M 1% | keys changed | 10K | 100–100K | 1.38 | 138 | 0.386 | 358× | 1.38 | −2% | +| index rebuild 1M main | entities | 1M | 10K–1M | 26.9 | 26.9 | 0.193 | 140× | 26.7 | +1% | +| index rebuild 1M workers | entities | 1M | 10K–1M | 6.28 | 6.28 | 0.0358 | 175× | 6.24 | −5% | +| rekey 1M | rows | 1M | 10K–1M | 12.5 | 12.5 | 0.289 | 43.3× | 12.2 | +2% | +| rekey 1M unindexed | rows | 1M | 10K–1M | 4.78 | 4.78 | 0.289 | 16.5× | 4.49 | +0% | +| order run 100K | rows | 100K | 100–100K | 3.13 | 31.3 | 0.161 | 194× | 3.11 | +0% | +| order run 100K storage order | rows | 100K | 100–100K | 3.02 | 30.2 | 0.0643 | 470× | 3.01 | +7% | +| order filtered 100K 1% | rows changed | 1K | 10–10K | 0.19 | 190 | 0.322 | 591× | 0.19 | +9% | +| unique lookup 1M | lookups | 1M | 100–100K | 53.2 | 53.2 | 0.193 | 276× | 53 | +21% slower | +| children lookup | children | 100K | 100–100K | 1.3 | 13 | 0.129 | 101× | 1.29 | −0% | +| sorted range 1M | range lookups | 10K | 100–100K | 5.62 | 562 | 1.54 | 364× | 5.6 | −0% | +| sorted page 100K | rows | 100K | 1K–100K | 0.0045 | 0.045 | 0.129 | 0.35× | -0.00836 | −9% | +| grid neighbors 100K main | rows | 100K | 1K–100K | 377 | 3,773 | 8.04 | 469× | 376 | +3% | +| grid neighbors 100K workers | rows | 100K | 1K–100K | 18 | 180 | 1.49 | 120× | 17.8 | +1% | +| run observer | runs | 1 | 1–1 | 0.000166 | 166 | 0.514 | 323× | 0.000165 | | +| observer idle 100 | observer systems | 1M | 1–100 | 84.5 | 84.5 | 0.514 | 164× | 83.9 | | +| joined observer resource | ticks | 1K | 1–1 | 4.6 | 4,598 | 0.514 | 8,937× | 4.6 | | +| joined observer 10K | ticks | 1K | 1–1 | 434 | 434,468 | 1,929 | 225× | 433 | | +| joined resource factory | ticks | 1K | 1–1 | 4.83 | 4,827 | 0.514 | 9,382× | 4.83 | | +| joined select 10K factory | ticks | 1K | 1–1 | 488 | 488,405 | 1,929 | 253× | 486 | | +| joined resource factory cadence | ticks | 100 | 1–1 | 5.52 | 55,151 | 0.514 | 107,200× | 5.52 | | +| read run observer | reads | 1 | 1–100 | 0.008 | 8,000 | 0.514 | 15,550× | 0.008 | | +| lookup 1K entities | lookups | 1K | 10–1K | 0.0212 | 21.2 | 0.129 | 165× | 0.0211 | | +| lookup 1K entities changed | lookups | 1K | 10–1K | 0.0234 | 23.4 | 0.193 | 121× | 0.0232 | | +| lookup 1K entities one changed | lookups | 1K | 10–1K | 0.0214 | 21.4 | 0.129 | 166× | 0.0212 | | +| lookup 1K entities changed one changed | lookups | 1K | 10–1K | 0.0235 | 23.5 | 0.193 | 122× | 0.0233 | | +| read observe factory warm | reads | 1 | 1–100 | 0.000125 | 125 | 0.514 | 243× | 0.000124 | | +| read observe factory cold | reads | 1 | 1–100 | 0.000833 | 833 | 0.514 | 1,619× | 0.000832 | | +| observe 1K entities factory | observers | 1K | 10–1K | 0.000458 | 0.458 | 0.129 | 3.56× | 0.000329 | | +| observe 1K entities one changed factory | observers | 1K | 10–1K | 0.0301 | 30.1 | 0.129 | 234× | 0.03 | | +| observe one entity among 100K moving factory | runs | 1 | 1–1 | 0.0445 | 44,500 | 6,431 | 6.92× | 0.0381 | | +| observe subscribe churn 1K factory | observers | 1K | 10–1K | 0.182 | 182 | 0.129 | 1,414× | 0.182 | | +| select page 100K factory | rows | 100K | 1K–100K | 0.0162 | 0.162 | 0.129 | 1.26× | 0.00335 | | +| select page 100K churn factory | reads | 1 | 1–100 | 0.00304 | 3,041 | 2.57 | 1,182× | 0.00304 | | +| select factory | rows | 1M | 100–100K | 7.62 | 7.62 | 0.0643 | 119× | 7.56 | | +| count 1M changed factory | rows | 1M | 1K–1M | 0.0422 | 0.0422 | 0.0643 | 0.656× | -0.0221 | | +| keyless index idle | indexes | 1K | 1–100 | 0.00704 | 7.04 | 0.0643 | 110× | 0.00698 | | +| keyless refresh 1M 1% | entities entering or leaving | 10K | 100–100K | 1.86 | 186 | 0.193 | 967× | 1.86 | | +| keyless spawn 1M 1% | entities spawned or despawned | 20K | 100–100K | 2.07 | 103 | 0.193 | 536× | 2.06 | | +| keyless write 1M | rows written | 1M | 10K–1M | 0.003 | 0.003 | 0.00000804 | 373× | 0.00299 | | +| keyless length | lookups | 100K | 1–1K | 0.469 | 4.69 | 0.129 | 36.5× | 0.456 | | + +History, ns per N, oldest first: + +| Benchmark | `3fdcd402` 10-18 | `eb4a7301+` 10-18 | `3c43e8ac` 10-18 | `dcec1e13+` 10-18 | `feecbf80+` 10-19 | `45cb8487+` 10-19 | `c20ed043+` 10-19 | `e3c30aa1+` 10-20 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| create | 57.1 | 58.3 | 58.8 | 59 | 58.5 | 58.1 | 58.2 | 58.4 | +| create flat | 63.9 | 66.2 | 66 | 66.9 | 67.3 | 65.1 | 64.5 | 66 | +| create flat dynamic | 91 | 92.6 | 92.5 | 94.2 | 91.1 | 91.8 | 94 | 92.3 | +| move | 0.36 | 0.364 | 0.362 | 0.366 | 0.362 | 0.361 | 0.361 | 0.359 | +| churn | 14.3 | 14.8 | 14.4 | 14.6 | 14.9 | 15.1 | 15.5 | 15.1 | +| retag | 35.6 | 32.8 | 32.9 | 33.5 | 32.2 | 32.2 | 32.5 | 32.3 | +| sparse changed | 0.321 | 0.424 | 0.519 | 0.322 | 0.321 | 0.323 | 0.323 | 0.318 | +| sparse changed clustered | 0.0226 | 0.0253 | 0.0262 | 0.023 | 0.024 | 0.023 | 0.0225 | 0.0222 | +| follow | 3.87 | 3.95 | 3.92 | 3.91 | 3.84 | 3.89 | 3.83 | 3.82 | +| emit | 8.78 | 9.01 | 9.03 | 8.83 | 8.79 | 8.8 | 8.69 | 8.77 | +| reduce | 0.504 | 0.504 | 0.504 | 0.513 | 0.505 | 0.504 | 0.504 | 0.504 | +| chain | 0.258 | 0.258 | 0.26 | 0.257 | 0.254 | 0.261 | 0.257 | 0.259 | +| up depth 10 | 2.04 | 2.1 | 2.07 | 2.08 | 2.06 | 2.05 | 2.04 | 2.02 | +| up depth 1000 | 2.38 | 2.4 | 2.62 | 2.48 | 2.38 | 2.41 | 2.39 | 2.39 | +| up one root changed | 0.02 | 0.0305 | 0.0351 | 0.0205 | 0.0202 | 0.021 | 0.0205 | 0.0244 | +| up after a spawn | 2.09 | 2.45 | 2.7 | 2.12 | 2.08 | 2.09 | 2.06 | 2.09 | +| up filtered after churn | 0.0422 | 0.05 | 0.0554 | 0.041 | 0.0399 | 0.0408 | 0.0392 | 0.0503 | +| up filtered after tagged churn | 0.0386 | 0.0478 | 0.0519 | 0.0398 | 0.0365 | 0.0434 | 0.0371 | 0.0413 | +| up filtered after a reparent | 0.0194 | 0.0206 | 0.0206 | 0.0207 | 0.0198 | 0.0197 | 0.0202 | 0.0196 | +| up load | 210 | 211 | 211 | 215 | 211 | 208 | 210 | 215 | +| up small tree in a big world | 10.8 | 11.2 | 11.3 | 11.5 | 11.1 | 10.9 | 11.4 | 10.6 | +| main thread | 6.64 | 6.77 | 7.05 | 6.64 | 6.71 | 6.57 | 6.58 | 6.56 | +| select | 8.16 | 8.25 | 8.21 | 8.24 | 8.22 | 8.16 | 8.16 | | +| move workers | 0.0777 | 0.0852 | 0.0825 | 0.147 | 0.0772 | 0.0768 | 0.0778 | 0.0795 | +| follow workers | 0.587 | 0.648 | 0.643 | 0.654 | 0.582 | 0.588 | 0.585 | 0.577 | +| run idle | 375 | 375 | 375 | 375 | 416 | 416 | 416 | 333 | +| run 50 idle | 113 | 115 | 114 | 115 | 107 | 113 | 112 | 70.8 | +| sync point one spawn | 875 | 916 | 916 | 875 | 916 | 916 | 916 | 833 | +| move 1K | 0.708 | 0.708 | 0.708 | 0.708 | 0.708 | 0.708 | 0.708 | 0.666 | +| move 10K | 0.433 | 0.446 | 0.433 | 0.437 | 0.433 | 0.437 | 0.433 | 0.421 | +| main thread 1K | 6.67 | 6.75 | 6.71 | 6.67 | 6.58 | 6.67 | 6.62 | 6.58 | +| main thread 10K | 6.28 | 6.43 | 6.3 | 6.37 | 6.22 | 6.25 | 6.26 | 6.26 | +| up depth 10 10K | 1.77 | 1.81 | 1.77 | 1.8 | 1.78 | 1.79 | 1.78 | 1.77 | +| spawn bulk | 25.3 | 25.8 | 25.8 | 25.5 | 25.5 | 25.6 | 25.6 | 25.2 | +| spawn bulk kernel | 10.3 | 10.3 | 10.4 | 10.4 | 10.3 | 10.4 | 10.5 | 10.5 | +| spawn singles | 281 | 291 | 291 | 281 | 291 | 291 | 292 | 271 | +| spawn sixteen archetypes | 39.9 | 42.2 | 42.7 | 43.6 | 40 | 40 | 40.8 | 40 | +| despawn bulk | 43.8 | 45.6 | 46.5 | 47.6 | 44.2 | 44.3 | 45.4 | 44.1 | +| despawn bulk kernel | 45.4 | 47.3 | 47.3 | 54.2 | 44.8 | 44.8 | 45.5 | 44 | +| despawn one | 1,541 | 1,583 | 1,541 | 1,625 | 1,583 | 1,583 | 1,583 | 1,500 | +| add tag | 95.4 | 96.2 | 96 | 102 | 95 | 96.2 | 96.5 | 95.4 | +| add tag kernel | 84.9 | 85.8 | 85.2 | 89.4 | 83.8 | 84.7 | 85.2 | 82.9 | +| add tag from four archetypes | 97.7 | 99.3 | 99.1 | 103 | 97.3 | 98.6 | 99.3 | 95.9 | +| add value | 96.6 | 97.3 | 97.3 | 97.6 | 96.5 | 96 | 99.4 | 95.5 | +| add several | 130 | 134 | 133 | 133 | 132 | 131 | 130 | 129 | +| remove tag | 70.3 | 72.8 | 70.6 | 71.7 | 70.3 | 70.6 | 71.2 | 70.1 | +| remove tag kernel | 60.3 | 61.2 | 60.7 | 59.8 | 59.5 | 60.3 | 59.3 | 59 | +| remove tag from four archetypes | 77.8 | 81 | 74.4 | 80.1 | 74.1 | 77.9 | 77.5 | 77.8 | +| remove several | 78.2 | 80.9 | 78.7 | 78.8 | 79.4 | 78.5 | 79 | 77.9 | +| add replace | 77.1 | 81.5 | 78.2 | 77.1 | 76.9 | 78.3 | 77.8 | 74.5 | +| spawn bulk kernel workers | 7.09 | 6.99 | 7.07 | 7.11 | 6.81 | 6.76 | 6.93 | 6.97 | +| loop tick idle | 1,847 | 1,886 | 1,900 | 1,988 | 1,855 | 1,881 | 1,885 | 1,948 | +| sync point grow 100 archetypes workers | 8,989 | 9,718 | 9,643 | 8,954 | 7,563 | 8,232 | 8,265 | 7,781 | +| wake | 710 | 742 | 746 | 741 | 717 | 707 | 704 | 781 | +| emit main | 8.7 | 9.13 | 9.05 | 8.88 | 8.72 | 8.86 | 8.72 | 8.68 | +| emit workers | 6.29 | 6.23 | 6.1 | 6.52 | 6.18 | 6.26 | 6.13 | 6.47 | +| remote run | 9,375 | 9,988 | 10,373 | 10,660 | 9,518 | 9,621 | 9,930 | 10,089 | +| mailbox resource | 4,977 | 5,297 | 5,294 | 5,150 | 5,230 | 5,224 | 5,161 | | +| mailbox select 10K | 745,676 | 770,295 | 767,713 | 784,134 | 752,444 | 754,225 | 749,652 | | +| log replay | 20.3 | 20.8 | 20.5 | 20.4 | 20.6 | 20.5 | 20.4 | 20.3 | +| log rebuild 100K | 3.06 | 3.22 | 3 | 3.32 | 3.05 | 3.12 | 3.03 | 3.32 | +| handoff | 10,447 | 10,856 | 10,871 | 12,096 | 10,091 | 10,790 | 10,488 | 9,979 | +| fused tiers | 6,330 | 6,893 | 6,894 | 7,015 | 6,755 | 6,219 | 6,784 | 6,566 | +| despawn objects 10K | 80.9 | 83.7 | 81.5 | 83.2 | 80 | 81.7 | 84 | 80.3 | +| tail prompt | 17,336 | 20,432 | 20,084 | 21,347 | 18,705 | 19,692 | 19,615 | 20,324 | +| tail late snapshot 10K | 7.02 | 8.23 | 8.73 | 8.48 | 6.97 | 7.05 | 6.99 | 8.24 | +| tail overlap | | | 2,506,557 | 2,556,813 | 2,431,830 | 2,544,020 | 2,444,427 | 2,440,215 | +| tail overlap waiting | | | 4,699,517 | 4,760,798 | 4,597,763 | 4,595,614 | 4,592,235 | 4,686,760 | +| read observe | | | | | 541 | 541 | 542 | | +| read run | | | | | 9,708 | 11,625 | 10,250 | 11,333 | +| read run changed | | | | | 12,042 | 12,708 | 11,917 | 12,166 | +| observe 1K entities | | | | | 25.9 | 24 | 24 | | +| observe 1K entities one changed | | | | | 26 | 24.9 | 25.1 | | +| select page 100K | | | | | 11.2 | 11.5 | 11.2 | | +| loop tick add remove | | | | | 23,621 | 26,830 | 27,112 | 24,368 | +| loop tick add remove again | | | | | 21,432 | 20,335 | 20,740 | 20,658 | +| loop tick idle after add remove | | | | | 1,942 | 1,986 | 1,990 | 1,778 | +| index idle | | | | | | 9.46 | 9.38 | 9.25 | +| index refresh 1M 1% | | | | | | 169 | 141 | 138 | +| index rebuild 1M main | | | | | | 27.5 | 26.7 | 26.9 | +| index rebuild 1M workers | | | | | | 6.52 | 6.61 | 6.28 | +| rekey 1M | | | | | | 14.3 | 12.3 | 12.5 | +| rekey 1M unindexed | | | | | | 4.84 | 4.77 | 4.78 | +| order run 100K | | | | | | 31.2 | 31.1 | 31.3 | +| order run 100K storage order | | | | | | 28.4 | 28.4 | 30.2 | +| order filtered 100K 1% | | | | | | 179 | 174 | 190 | +| unique lookup 1M | | | | | | 45.2 | 44 | 53.2 | +| children lookup | | | | | | 32.7 | 13.1 | 13 | +| sorted range 1M | | | | | | 840 | 563 | 562 | +| sorted page 100K | | | | | | 0.234 | 0.0496 | 0.045 | +| grid neighbors 100K main | | | | | | 11,099 | 3,656 | 3,773 | +| grid neighbors 100K workers | | | | | | 178 | 179 | 180 | +| run observer | | | | | | | | 166 | +| observer idle 100 | | | | | | | | 84.5 | +| joined observer resource | | | | | | | | 4,598 | +| joined observer 10K | | | | | | | | 434,468 | +| joined resource factory | | | | | | | | 4,827 | +| joined select 10K factory | | | | | | | | 488,405 | +| joined resource factory cadence | | | | | | | | 55,151 | +| read run observer | | | | | | | | 8,000 | +| lookup 1K entities | | | | | | | | 21.2 | +| lookup 1K entities changed | | | | | | | | 23.4 | +| lookup 1K entities one changed | | | | | | | | 21.4 | +| lookup 1K entities changed one changed | | | | | | | | 23.5 | +| read observe factory warm | | | | | | | | 125 | +| read observe factory cold | | | | | | | | 833 | +| observe 1K entities factory | | | | | | | | 0.458 | +| observe 1K entities one changed factory | | | | | | | | 30.1 | +| observe one entity among 100K moving factory | | | | | | | | 44,500 | +| observe subscribe churn 1K factory | | | | | | | | 182 | +| select page 100K factory | | | | | | | | 0.162 | +| select page 100K churn factory | | | | | | | | 3,041 | +| select factory | | | | | | | | 7.62 | +| count 1M changed factory | | | | | | | | 0.0422 | +| keyless index idle | | | | | | | | 7.04 | +| keyless refresh 1M 1% | | | | | | | | 186 | +| keyless spawn 1M 1% | | | | | | | | 103 | +| keyless write 1M | | | | | | | | 0.003 | +| keyless length | | | | | | | | 4.69 | + +## Benchmarks + +### create + +Spawning entities of six components, two of them tags, over four archetypes, through a main-thread system and the sync point that applies them: loading a level, a burst of particles. + +- **N:** 1M spawns; usually 100–100K. +- **Best:** 48 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location. + +### create flat + +The same spawns with eight scalar components each, values passed inline. + +- **N:** 1M spawns; usually 100–100K. +- **Best:** 48 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location. + +### create flat dynamic + +The same spawns from value objects built first, as spawns from data or from another system's results are. + +- **N:** 1M spawns; usually 100–100K. +- **Best:** 48 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location. + +### move + +A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update. + +- **N:** 250K matched rows; usually 1K–1M. +- **Best:** 36 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 12 bytes of velocity and reads and writes 12 bytes of position. + +### churn + +A main-thread system despawning a tenth of its rows and spawning as many: projectiles, particles, pooled UI. + +- **N:** 1M rows visited; usually 1K–100K. +- **Best:** 29 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 16 bytes; each tenth swaps the last row into its hole, 88 bytes, and spawns one of 44. + +### retag + +A main-thread system adding a tag to a tenth of its rows and removing it from another tenth: state flags such as selected or visible that move entities between archetypes. + +- **N:** 1M rows visited; usually 1K–100K. +- **Best:** 39 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 4 bytes; each fifth moves, copying its 44 bytes and swapping the last row into its hole. + +### sparse changed + +A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved. + +- **N:** 1M rows in the archetype; usually 10K–1M. +- **Best:** 8.24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12. + +### sparse changed clustered + +The same with the 1% in one contiguous run of rows, as edits to recently spawned entities are. + +- **N:** 1M rows in the archetype; usually 10K–1M. +- **Best:** 0.33 bytes per N at the copy bandwidth of one thread, the fastest it measured. Chunk ticks are scanned; the changed 1% read their 8-byte ticks and 24 bytes of values. + +### follow + +A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 52 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12. + +### emit + +A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 34 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes. + +### reduce + +A kernel folding every row into a sum and a maximum: totals, bounds, counts. + +- **N:** 1M rows; usually 1K–1M. +- **Best:** 4 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 4-byte value. + +### chain + +Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps. + +- **N:** 6M row passes; usually 6K–6M. +- **Best:** 4 bytes per N at the copy bandwidth of one thread, the fastest it measured. Fused into one pass, each row reads and writes its 12-byte velocity once for all six. + +### up depth 10 + +A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result. + +### up depth 1000 + +A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result. + +### up one root changed + +A filtered kernel with `up` after one root's value changed: moving one object in a scene graph. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 0.00781 bytes per N at the copy bandwidth of one thread, the fastest it measured. Chunk ticks are scanned; the 10 rows of the changed chain run. + +### up after a spawn + +A kernel with `up` computing every row, after a spawn into its tree: a node added to a scene graph each frame. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. The run itself, with the order kept current at O(1) for the spawn. + +### up filtered after churn + +A filtered kernel with `up` after one root changed and 100 leaves were despawned and 100 spawned in the tree's archetype: a scene graph with objects coming and going. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 0.0137 bytes per N at the copy bandwidth of one thread, the fastest it measured. Chunk ticks are scanned; the changed chain and the 200 leaves run; the order is patched for 200 rows. + +### up filtered after tagged churn + +A filtered kernel with `up` after one root changed and 100 leaves were despawned and 100 spawned, the leaves in an archetype of their own: a scene graph with short-lived children of their own kind, such as effects. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 0.0137 bytes per N at the copy bandwidth of one thread, the fastest it measured. Chunk ticks are scanned; the changed chain and the 200 leaves run; the order is patched for 200 rows. + +### up filtered after a reparent + +A filtered kernel with `up` after a subtree of 9 rows moved to another parent: dragging an object to a new group. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 0.00781 bytes per N at the copy bandwidth of one thread, the fastest it measured. Chunk ticks are scanned; the 9 moved rows are renumbered and run. + +### up load + +Spawning a forest of 100K chains of 10 and running a kernel with `up` over it once: loading a scene. + +- **N:** 1M spawns; usually 1K–100K. +- **Best:** 68 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity writes 24 bytes of values, its entity and its location, then runs once. + +### up small tree in a big world + +A kernel with `up` over a tree of 1K rows after a spawn into it, in a world of 1M other entities: a UI or a rig beside a large simulation. + +- **N:** 1K tree rows; usually 100–10K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. The run over the tree, with the order kept current at O(1) for the spawn. + +### main thread + +Fifty main-thread systems each reading every row of 1K: many small app systems. + +- **N:** 50K row reads; usually 1K–100K. +- **Best:** 12 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row read loads its 12-byte position. + +### select + +The first value of `observe.select` with `where`, given while subscribing: a query from UI or tools. + +- **N:** 1M rows; usually 100–100K. +- **Best:** 8 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 4-byte index; each selected half writes its 8-byte entity. + +### move main + +A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update. + +- **N:** 250K matched rows; usually 1K–1M. +- **Best:** 36 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 12 bytes of velocity and reads and writes 12 bytes of position. + +### follow main + +A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 52 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12. + +### chain main + +Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps. + +- **N:** 6M row passes; usually 6K–6M. +- **Best:** 4 bytes per N at the copy bandwidth of one thread, the fastest it measured. Fused into one pass, each row reads and writes its 12-byte velocity once for all six. + +### sparse changed main + +A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved. + +- **N:** 1M rows in the archetype; usually 10K–1M. +- **Best:** 8.24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12. + +### up depth 10 main + +A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result. + +### up depth 1000 main + +A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result. + +### move workers + +A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update. + +- **N:** 250K matched rows; usually 1K–1M. +- **Best:** 36 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each row reads 12 bytes of velocity and reads and writes 12 bytes of position. + +### follow workers + +A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 52 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12. + +### chain workers + +Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps. + +- **N:** 6M row passes; usually 6K–6M. +- **Best:** 4 bytes per N at the copy bandwidth of all workers, the fastest it measured. Fused into one pass, each row reads and writes its 12-byte velocity once for all six. + +### sparse changed workers + +A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved. + +- **N:** 1M rows in the archetype; usually 10K–1M. +- **Best:** 8.24 bytes per N at the copy bandwidth of all workers, the fastest it measured. Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12. + +### up depth 10 workers + +A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result. + +### up depth 1000 workers + +A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result. + +### up depth 10 gpu + +A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 16 bytes per N at the copy bandwidth of the GPU, the fastest it measured. Each row reads its 4-byte value, its parent's 4-byte result and 4-byte row, and writes its 4-byte result. + +### up depth 1000 gpu + +A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 16 bytes per N at the copy bandwidth of the GPU, the fastest it measured. Each row reads its 4-byte value, its parent's 4-byte result and 4-byte row, and writes its 4-byte result. + +### run idle + +Running one system no entity matches, in a world of 10K entities: the fixed cost of every system in every frame. + +- **N:** 1 runs; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A run reads at least one cache line of its system's state. + +### run 50 idle + +Running fifty systems no entity matches, as one composite: the fixed cost per system of a frame of many systems. + +- **N:** 50 systems; usually 10–200. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A run reads at least one cache line of its system's state. + +### sync point one spawn + +Running a system that spawns one entity, so its run ends with a sync point that applies one command: the fixed cost of a structural change. + +- **N:** 1 runs; usually 1–1. +- **Best:** 128 bytes per N at the copy bandwidth of one thread, the fastest it measured. The spawn writes its 28 bytes of values, its entity and its location, and the run reads its system's state. + +### move 1K + +A kernel adding a vec3 to another for the rows with two tags, at a size a typical system sees. + +- **N:** 1K matched rows; usually 1K–100K. +- **Best:** 36 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 12 bytes of velocity and reads and writes 12 bytes of position. + +### move 10K + +A kernel adding a vec3 to another for the rows with two tags, at a size a typical system sees. + +- **N:** 10K matched rows; usually 1K–100K. +- **Best:** 36 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 12 bytes of velocity and reads and writes 12 bytes of position. + +### main thread 1K + +One main-thread system reading every row: app logic over a typical number of entities. + +- **N:** 1K row reads; usually 1K–100K. +- **Best:** 12 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row read loads its 12-byte position. + +### main thread 10K + +One main-thread system reading every row: app logic over a typical number of entities. + +- **N:** 10K row reads; usually 1K–100K. +- **Best:** 12 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row read loads its 12-byte position. + +### up depth 10 10K + +A kernel with `up` computing every row from its parent, over 1K chains of 10: a scene graph of typical size. + +- **N:** 10K rows; usually 1K–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result. + +### spawn bulk + +A main-thread system spawning 100K entities of one archetype, applied by its sync point: loading many of one kind. + +- **N:** 100K spawns; usually 100–100K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity writes its 8 bytes of values, its 8-byte entity and its 8-byte location. + +### spawn bulk kernel + +A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits. + +- **N:** 100K spawns; usually 100–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location. + +### spawn singles + +A run spawning one entity of each of four archetypes: the fixed cost of a few spawns a frame, such as a click or a message. + +- **N:** 4 spawns; usually 1–10. +- **Best:** 40 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity writes its values, entity and location, and the run reads a cache line of its system's state. + +### spawn sixteen archetypes + +Spawns from value objects over sixteen archetypes, more component sets than spawns cache: entities of many kinds, from data. + +- **N:** 100K spawns; usually 100–100K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity writes its 8 bytes of values, its 8-byte entity and its 8-byte location. + +### despawn bulk + +A main-thread system despawning 100K entities over four archetypes: unloading a level. + +- **N:** 100K despawns; usually 100–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each despawn frees its 8-byte location and entity; the archetypes empty, so no row moves. + +### despawn bulk kernel + +The same despawns from a kernel. + +- **N:** 100K despawns; usually 100–100K. +- **Best:** 20 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 4-byte index; each despawn frees its location and entity. + +### despawn one + +A run despawning one entity of 100K: the fixed cost of a despawn, such as one projectile hitting. + +- **N:** 1 despawns; usually 1–10. +- **Best:** 96 bytes per N at the copy bandwidth of one thread, the fastest it measured. The despawn swaps the last row into its hole and frees its location, and the run reads its system's state. + +### add tag + +Adding a tag to 100K entities of one archetype: a state flag set on many at once. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### add tag kernel + +The same adds from a kernel. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 20 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### add tag from four archetypes + +Adding a tag to 100K entities over four archetypes, each moving to its own target. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### add value + +Adding a valued component to 100K entities of one archetype: attaching data such as a timer or a score. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 20 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### add several + +Adding a tag and two values at once to 100K entities: attaching a bundle of components. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### remove tag + +Removing a tag from 100K entities of one archetype. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### remove tag kernel + +The same removes from a kernel. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 20 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### remove tag from four archetypes + +Removing a tag from 100K entities over four archetypes. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### remove several + +Removing a tag and two values at once from 100K entities. + +- **N:** 100K entities moved; usually 100–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole. + +### add replace + +Adding a value 100K entities already have: values replaced and marked changed, with no archetype moves. + +- **N:** 100K entities; usually 100–100K. +- **Best:** 12 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 4-byte value and writes 4 bytes, plus its 4-byte location read. + +### spawn bulk kernel main + +A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits. + +- **N:** 100K spawns; usually 100–100K. +- **Best:** 28 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location. + +### spawn bulk kernel workers + +A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits. + +- **N:** 100K spawns; usually 100–100K. +- **Best:** 28 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location. + +### loop tick idle + +A world's loop ticking with nothing changed, driven back to back with `always` set, counted by an observer system reading its clock: the fixed cost of every tick. + +- **N:** 10K ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A run reads at least one cache line of its system's state. + +### sync point grow 100 archetypes main + +A sync point where each of 100 archetypes grows, by one spawn into each: levels and bursts filling many kinds of entities at once. + +- **N:** 100 archetypes grown; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each archetype that grows writes at least its layout record, a cache line. + +### sync point grow 100 archetypes workers + +A sync point where each of 100 archetypes grows, by one spawn into each, then a kernel over them, so workers take up the new layout. + +- **N:** 100 archetypes grown; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each archetype that grows writes at least its layout record, a cache line. + +### wake + +A task posted to a pool worker waiting in its window, which wakes, marks it done and wakes the world's thread: the fixed cost of every tier on workers. + +- **N:** 10K round trips; usually 1–100. +- **Best:** 128 bytes per N at the copy bandwidth of one thread, the fastest it measured. A round trip moves at least the two cache lines of the ring's posted and done words between cores. + +### emit main + +A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 34 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes. + +### emit workers + +A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass. + +- **N:** 1M rows; usually 1K–100K. +- **Best:** 34 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes. + +### remote run + +An empty transaction run from a joined thread, through its run slots and completed word: input from another thread, such as the DOM on main driving a world on a worker. + +- **N:** 1K runs; usually 1–100. +- **Best:** 192 bytes per N at the copy bandwidth of one thread, the fastest it measured. A run moves at least its slot's line and the posted and completed words' lines between cores. + +### mailbox resource + +Ticks that change one resource a joined thread observes, each writing its mailbox and waking it once: a UI on main showing a world's state. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A tick reads at least one cache line of its system's state. + +### mailbox select 10K + +Ticks that reverse the order of 10K entities a joined thread selects in order, each recomputing the selection and writing it into the mailbox: a sorted list shown on main. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 240,000 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each tick writes 10K 8-byte values, reads them to sort, and writes 10K 8-byte entities. + +### log replay + +A joined thread replaying a sync point's move log: 10K entities holding its objects moving archetype, each leaving a row the last swaps into, as when a tag on DOM-backed entities toggles. + +- **N:** 10K moves; usually 1–10K. +- **Best:** 128 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each move copies out and reads two 16-byte entries, and copies its object, ticks and entity twice, 32 bytes read and written each. + +### log rebuild 100K + +A joined thread rebuilding its object columns over 100K rows from the layout directory, as when it fell further behind than its log holds. + +- **N:** 100K rows; usually 1K–100K. +- **Best:** 60 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte entity, 4-byte tag and 16 bytes of ticks, and writes its entity, object and ticks. + +### handoff + +Ticks of ten tiers alternating the world's thread and a joined worker, empty systems: five tasks posted and waited for each tick, as when systems that write a worker's objects sit between the world's. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 1,280 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each of five handoffs moves at least the task's posted, done, payload and completion lines between cores. + +### fused tiers + +Ticks of ten consecutive tiers on a joined worker, empty systems, which run as one task: a worker filling objects whose systems follow one another. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 256 bytes per N at the copy bandwidth of one thread, the fastest it measured. One task moves at least its posted, done, payload and completion lines between cores. + +### despawn objects 10K + +A sync point despawning 10K entities whose objects a joined worker fills, with a finalizer there reading the object and a vec3: releasing DOM nodes or GPU handles, which the world's thread doesn't wait for. + +- **N:** 10K despawns; usually 1–10K. +- **Best:** 56 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each despawn frees its 8-byte location and entity, and writes its stop: its entity, archetype, row and tag, 20 bytes, and its 12-byte vec3, read and written. + +### tail prompt + +Ticks of a world system and a terminal system on a joined worker over 1,000 rows, which runs after the sync point and finishes before the next tick needs it: a DOM-writing system on main that keeps up. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 12,256 bytes per N at the copy bandwidth of one thread, the fastest it measured. One task moves its posted, done, payload and completion lines between cores, and the tail reads each row's 12-byte vec3. + +### tail late snapshot 10K + +A run writing what a late tail on a joined worker reads, 10,000 passing rows of a vec3 and a mat4: the copy of the tail's reads it makes first, as when main is busy when the next tick writes positions. + +- **N:** 10K rows copied; usually 100–10K. +- **Best:** 172 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row's 12-byte vec3 and 64-byte mat4 are read and written, and its position, archetype, row and entity, 20 bytes, written. + +### tail overlap + +Ticks writing every row's place, which the last tick's tail on a joined worker still reads, then working 2 ms, while the tail works about 2.5 ms over 10,000 rows of a place and a pose: main's DOM work and the world's work after its writer that together outlast a frame or a fixed step, though each fits. The writer copies the rows the tail hasn't read, so the tick and the tail overlap. + +- **N:** 100 ticks; usually 1–1. +- **Best:** 1,520,000 bytes per N at the copy bandwidth of one thread, the fastest it measured. The world copies each row's 12-byte vec3 and 64-byte mat4 the tail hasn't read, and the tail reads them, beyond the work each side does. + +### tail overlap waiting + +Ticks writing every row's place, which the last tick's tail on a joined worker still reads, then working 2 ms, while the tail works about 2.5 ms over 10,000 rows of a place and a pose: main's DOM work and the world's work after its writer that together outlast a frame or a fixed step, though each fits. The writer waits for the tail to finish instead, the alternative `tail overlap` is measured against. + +- **N:** 100 ticks; usually 1–1. +- **Best:** 760,000 bytes per N at the copy bandwidth of one thread, the fastest it measured. The tail reads each row's 12-byte vec3 and 64-byte mat4, beyond the work each side does. + +### transfer + +Runs from a loader on a joined worker, each moving a 1 MB ArrayBuffer and a 1024 by 1024 ImageBitmap to the joined worker whose system takes them, which acknowledges each to the world: decoded textures or audio reaching the thread that uses them. + +- **N:** 100 transfers; usually 1–100. +- **Best:** 256 bytes per N at the copy bandwidth of one thread, the fastest it measured. A transfer moves its values' handles in one message, the bytes staying where they are, and the run's slot and completion lines between cores. + +### read observe + +Reading a resource through an observer's first value, `Observe.toPromise(world.observe.resource(key))`: a value read from outside the world. + +- **N:** 1 reads; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A read loads at least one cache line of the resource's cell. + +### read run + +Reading a resource through a run of a new system that copies it out: a value read from outside the world through a system made for the read. + +- **N:** 1 reads; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A read loads at least one cache line of the resource's cell. + +### read run changed + +Reading the ranks that changed through a run of a new system filtering on `changed`, with a kernel writing them: a read whose filter turns the kernel's stamps on while the system lives. + +- **N:** 1 reads; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A read loads at least one cache line of a chunk's tick. + +### observe 1K entities + +A run that changes nothing with an entity observer on each of 1K entities: what watching entities costs each frame. + +- **N:** 1K observers; usually 10–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each observer reads its entity's 8-byte location and its component's 8-byte changed tick. + +### observe 1K entities one changed + +A run that changes one of 1K entities, each with an entity observer: what watching entities costs each frame while one of them changes. + +- **N:** 1K observers; usually 10–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each observer reads its entity's 8-byte location and its component's 8-byte changed tick. + +### select page 100K + +A run that changes one entity's order component under a `select` observer with `where`, `order` and a 20-row `page` over 100K entities: a sorted, filtered list of a UI, recomputed after a change. + +- **N:** 100K rows; usually 1K–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte rank and its 8-byte value. + +### loop tick add remove + +Ticks back to back, each adding a new system with a change filter over 100 rows and removing the one the last tick added: systems that come and go with what a UI shows. + +- **N:** 10K ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A tick reads at least one cache line of its systems' state. + +### loop tick add remove again + +Ticks back to back, each adding or removing one module's system with a change filter over 100 rows: a feature switched on and off. + +- **N:** 10K ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A tick reads at least one cache line of its systems' state. + +### loop tick idle after add remove + +Ticks with nothing changed after 60,000 ticks that each added a new system and removed the last: what systems that came and went leave behind in every tick. + +- **N:** 10K ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A tick reads at least one cache line of its systems' state. + +### index idle + +A run of a system looking up through 1,000 indexes when nothing they hold changed: what keeping an index costs each frame while its entities rest. + +- **N:** 1K indexes; usually 1–100. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each index reads its journal position and its key's last write, 8 bytes each. + +### index refresh 1M 1% + +Refreshing a hash index of 1M entities after a system moved 1% of them to other keys: an index kept current as some entities change their key each frame. + +- **N:** 10K keys changed; usually 100–100K. +- **Best:** 48 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each changed row reads its 8-byte key and tick, and moves a 12-byte entry out of one bucket and into another. + +### index rebuild 1M main + +Building a hash index of 1M entities again after a system moved every one of them to another key, on the world's thread: an index whose keys all change at once. + +- **N:** 1M entities; usually 10K–1M. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity reads its 8-byte key and location and writes a 12-byte entry. + +### index rebuild 1M workers + +Building a hash index of 1M entities again after a system moved every one of them to another key, across the pool: an index whose keys all change at once. + +- **N:** 1M entities; usually 10K–1M. +- **Best:** 24 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each entity reads its 8-byte key and location and writes a 12-byte entry. + +### rekey 1M + +A frame of 1M particles drifting a quarter unit, a kernel writing each one's cell of 4 units only when it crosses into another, and the index on the cell refreshed for its reader: a computed key kept as a component. + +- **N:** 1M rows; usually 10K–1M. +- **Best:** 36 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads and writes its 12-byte position and reads its 12-byte cell; a crossing also moves its 12-byte entry. + +### rekey 1M unindexed + +The frame of `rekey 1M` without the index: the drift and the kernel computing each particle's cell, what the key costs before the index keeps it. + +- **N:** 1M rows; usually 10K–1M. +- **Best:** 36 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads and writes its 12-byte position and reads its 12-byte cell. + +### order run 100K + +A main-thread system running 100K rows in the order of a sorted index whose ranks are scattered through storage: views laid out by sibling rank, timelines in time order. + +- **N:** 100K rows; usually 100–100K. +- **Best:** 20 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 12-byte entry in key order and its 8-byte value. + +### order run 100K storage order + +The system of `order run 100K` without `order`, its rows in storage order: what running the rows costs alone. + +- **N:** 100K rows; usually 100–100K. +- **Best:** 8 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte value. + +### order filtered 100K 1% + +A main-thread system with a change filter running, in a sorted index's order, the 1% of 100K rows that changed since it last ran: reacting to edits in document order. + +- **N:** 1K rows changed; usually 10–10K. +- **Best:** 40 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each changed row reads its tick, its entry's position and its 12-byte entry, and its 8-byte value. + +### unique lookup 1M + +1M lookups by key through a unique index, each reading one value of the row it finds, in a main-thread system: a replication map from server ids to entities. + +- **N:** 1M lookups; usually 100–100K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each lookup reads its bucket's 8-byte slot and its 12-byte entry, and a 4-byte value. + +### children lookup + +Each of 10K parents summing a value of its ten children through an index on their parent reference, in a main-thread system: a reverse relationship. + +- **N:** 100K children; usually 100–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each child is a 12-byte entry and a 4-byte value read. + +### sorted range 1M + +10K lookups of a range of ten keys through a sorted index of 1M entities, each reading a value of its rows, in a main-thread system: timelines, intervals, nearest by order. + +- **N:** 10K range lookups; usually 100–100K. +- **Best:** 192 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each lookup reads two lines of the blocks' first keys and ten 16-byte entries and their 4-byte values. + +### sorted page 100K + +A run that changes one entity's value, and a system reading the 20 rows after the first 100 with a small key, highest values first, through a sorted index on the value, recomputed when it changed: `select page 100K` as a system reading an index. + +- **N:** 100K rows; usually 1K–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte rank and its 8-byte value, as `select page 100K`'s rows do. + +### grid neighbors 100K main + +Each of 100K particles reading the positions of those in the 27 cells around its own through a hash index on its cell, in a main-thread system: a uniform grid broadphase. + +- **N:** 100K rows; usually 1K–100K. +- **Best:** 1,000 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row probes 27 buckets, a 64-byte line each, and reads about 270 neighbors' 12-byte entries and positions. + +### grid neighbors 100K workers + +Each of 100K particles reading the positions of those in the 27 cells around its own through a hash index on its cell, in a kernel across the pool: a uniform grid broadphase. + +- **N:** 100K rows; usually 1K–100K. +- **Best:** 1,000 bytes per N at the copy bandwidth of all workers, the fastest it measured. Each row probes 27 buckets, a 64-byte line each, and reads about 270 neighbors' 12-byte entries and positions. + +### run observer + +Running one observer system no entity matches, in a world of 10K entities: a run with no tiers, its sync point empty, against `run idle`. + +- **N:** 1 runs; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A run reads at least one cache line of its system's state. + +### observer idle 100 + +Ticks as in `loop tick idle`, with a hundred observer systems no entity matches added, each run after every tick: the cost per observer system per pass, a hundredth of the tick's own cost included. + +- **N:** 1M observer systems; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A run reads at least one cache line of its system's state. + +### joined observer resource + +Ticks that change one resource an observer system a joined thread added reads after each sync point, posted to it and waking it once. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A tick reads at least one cache line of its system's state. + +### joined observer 10K + +Ticks that reverse the order of 10K entities an observer system a joined thread added reads through an index and sorts after each sync point. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 240,000 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each tick writes 10K 8-byte values, reads them to sort, and sorts 10K 8-byte entities. + +### joined resource factory + +Ticks that change one resource a joined thread observes through `Observe.resource`, an observer system it added, posted to it and waking it once. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A tick reads at least one cache line of its system's state. + +### joined select 10K factory + +Ticks that reverse the order of 10K entities a joined thread selects in order through `Observe.select`. + +- **N:** 1K ticks; usually 1–1. +- **Best:** 240,000 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each tick writes 10K 8-byte values, reorders them in the index, and reads 10K 8-byte entities. + +### joined resource factory cadence + +`joined resource factory` at a display's cadence, frames 2 ms apart, the joined thread keeping up: what the world's thread spends on a tick, from its frame until the tick is done. + +- **N:** 100 ticks; usually 1–1. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A tick reads at least one cache line of its system's state. + +### read run observer + +Reading a resource through a run of a new observer system that copies it out, which has no tiers and an empty sync point: as `read run`, through an observer system. + +- **N:** 1 reads; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A read loads at least one cache line of the resource's cell. + +### lookup 1K entities + +A system looking up 1K entities by entity, reading a value of each, with nothing changed: following references, without `changed`. + +- **N:** 1K lookups; usually 10–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each lookup reads its entity's 8-byte location and its 8-byte value. + +### lookup 1K entities changed + +The same with `changed`, with nothing changed: watching entities a system follows for changes since it last ran. + +- **N:** 1K lookups; usually 10–1K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each lookup reads its entity's 8-byte location, its 8-byte value and its 8-byte changed tick. + +### lookup 1K entities one changed + +A run writing one of 1K entities, then a system looking up each by entity, reading its value, without `changed`. + +- **N:** 1K lookups; usually 10–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each lookup reads its entity's 8-byte location and its 8-byte value. + +### lookup 1K entities changed one changed + +The same with `changed`: one of the entities a system follows changed since it last ran. + +- **N:** 1K lookups; usually 10–1K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each lookup reads its entity's 8-byte location, its 8-byte value and its 8-byte changed tick. + +### read observe factory warm + +Reading a resource through an observer factory's first value, `Observe.toPromise(Observe.resource(world, key))`, while the factories hold its value: a value read from outside the world, again. + +- **N:** 1 reads; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A read loads at least one cache line of the resource's cell. + +### read observe factory cold + +Reading a resource through an observer factory's first value while the factories hold nothing of it, their observer system for it removed after idle passes: a run of observer systems for one read. + +- **N:** 1 reads; usually 1–100. +- **Best:** 64 bytes per N at the copy bandwidth of one thread, the fastest it measured. A read loads at least one cache line of the resource's cell. + +### observe 1K entities factory + +A run that changes nothing with an entity observer on each of 1K entities, through the observer factories: what watching entities costs each frame. + +- **N:** 1K observers; usually 10–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each observer reads its entity's 8-byte location and its component's 8-byte changed tick. + +### observe 1K entities one changed factory + +A run that changes one of 1K entities, each with an entity observer, through the observer factories: what watching entities costs each frame while one of them changes. + +- **N:** 1K observers; usually 10–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each observer reads its entity's 8-byte location and its component's 8-byte changed tick. + +### observe one entity among 100K moving factory + +A kernel writing the rank of 100K entities, one of them observed, through the observer factories: a debugging view of one entity in a moving world, beyond the kernel's own time. + +- **N:** 1 runs; usually 1–1. +- **Best:** 800,000 bytes per N at the copy bandwidth of one thread, the fastest it measured. The kernel reads and writes 100K 4-byte ranks; observing one entity adds a cache line. + +### observe subscribe churn 1K factory + +Subscribing an entity observer to each of 1K entities, a run, and stopping them all, through the observer factories: views opened and closed each frame. + +- **N:** 1K observers; usually 10–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each observer reads its entity's 8-byte location and its component's 8-byte value. + +### select page 100K factory + +A run that changes one entity's order component under an observer factory's `select` with `where`, `order` and a 20-row `page` over 100K entities: a sorted, filtered list of a UI, kept current through a sorted index. + +- **N:** 100K rows; usually 1K–100K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte rank and its 8-byte value. + +### select page 100K churn factory + +The first value of a 20-row page of a `select` with `where` and `order` over 100K entities, at another offset each time, while another observer of that query keeps it current, through the observer factories: a UI paging. + +- **N:** 1 reads; usually 1–100. +- **Best:** 320 bytes per N at the copy bandwidth of one thread, the fastest it measured. A page reads 20 entries of 16 bytes: an entity, its rank and its value. + +### select factory + +The first value of an observer factory's `select` with `where` over 1M entities, which the factories hold nothing of: one scan, a run of an observer system over the rows that match. + +- **N:** 1M rows; usually 100–100K. +- **Best:** 8 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 4-byte rank; each selected half writes its 8-byte entity. + +### count 1M changed factory + +A run that changes one entity's value under a `count` with `where` over 1M entities, through the observer factories: a filtered total of a UI, counted again after a change. + +- **N:** 1M rows; usually 1K–1M. +- **Best:** 8 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each row reads its 8-byte value. + +### keyless index idle + +A run of a system looking up through 1,000 indexes without a key when nothing they hold changed: what keeping a membership costs each frame while its entities rest. + +- **N:** 1K indexes; usually 1–100. +- **Best:** 8 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each index reads its journal position, 8 bytes. + +### keyless refresh 1M 1% + +Refreshing an index without a key of 1M entities after 1% of them, spread through entity order, left it or entered it again at a sync point: a membership kept current as some entities gain or lose a tag each frame. + +- **N:** 10K entities entering or leaving; usually 100–100K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity reads its 4-byte journal entry and its 8-byte location, and moves a 12-byte entry in or out. + +### keyless spawn 1M 1% + +Refreshing an index without a key of 1M entities after a sync point despawned the 1% spawned the frame before and spawned as many again: a membership of entities that come and go in bursts, as particles do. + +- **N:** 20K entities spawned or despawned; usually 100–100K. +- **Best:** 24 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each entity reads its 4-byte journal entry and its 8-byte location, and moves a 12-byte entry in or out. + +### keyless write 1M + +Refreshing an index without a key of 1M entities after a kernel wrote a component every one of them has, which the index holds by but no lookup watches: a membership costs nothing for the values its entities write each frame. + +- **N:** 1M rows written; usually 10K–1M. +- **Best:** 0.001 bytes per N at the copy bandwidth of one thread, the fastest it measured. Nothing it holds changed: the index reads its journal position and nothing per row. + +### keyless length + +100K lookups through an index without a key of 1M entities, each reading its length and whether it changed, in a main-thread system: a count a system keeps. + +- **N:** 100K lookups; usually 1–1K. +- **Best:** 16 bytes per N at the copy bandwidth of one thread, the fastest it measured. Each lookup reads its length and its range's newest tick, 8 bytes each. + +### observe gpu entity factory + +A GPU kernel writing a value of 100K entities, one entity of which is observed through the observer factories: each run reads that storage back for it. + +- **N:** 1 runs; usually 1–1. +- **Best:** 400,000 bytes per N at the copy bandwidth of the GPU, the fastest it measured. The readback copies the observed storage's 100K 4-byte values. diff --git a/packages/ecs/perf/benchmarks.json b/packages/ecs/perf/benchmarks.json new file mode 100644 index 000000000..019272714 --- /dev/null +++ b/packages/ecs/perf/benchmarks.json @@ -0,0 +1,1771 @@ +{ + "create": { + "operation": "Spawning entities of six components, two of them tags, over four archetypes, through a main-thread system and the sync point that applies them: loading a level, a burst of particles.", + "per": "spawns", + "n": 1000000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 48, + "basis": "Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location." + } + }, + "create flat": { + "operation": "The same spawns with eight scalar components each, values passed inline.", + "per": "spawns", + "n": 1000000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 48, + "basis": "Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location." + } + }, + "create flat dynamic": { + "operation": "The same spawns from value objects built first, as spawns from data or from another system's results are.", + "per": "spawns", + "n": 1000000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 48, + "basis": "Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location." + } + }, + "move": { + "operation": "A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update.", + "per": "matched rows", + "n": 250000, + "typical": [ + 1000, + 1000000 + ], + "best": { + "bytes": 36, + "basis": "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position." + } + }, + "churn": { + "operation": "A main-thread system despawning a tenth of its rows and spawning as many: projectiles, particles, pooled UI.", + "per": "rows visited", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 29, + "basis": "Each row reads 16 bytes; each tenth swaps the last row into its hole, 88 bytes, and spawns one of 44." + } + }, + "retag": { + "operation": "A main-thread system adding a tag to a tenth of its rows and removing it from another tenth: state flags such as selected or visible that move entities between archetypes.", + "per": "rows visited", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 39, + "basis": "Each row reads 4 bytes; each fifth moves, copying its 44 bytes and swapping the last row into its hole." + } + }, + "sparse changed": { + "operation": "A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved.", + "per": "rows in the archetype", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 8.24, + "basis": "Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12." + } + }, + "sparse changed clustered": { + "operation": "The same with the 1% in one contiguous run of rows, as edits to recently spawned entities are.", + "per": "rows in the archetype", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 0.33, + "basis": "Chunk ticks are scanned; the changed 1% read their 8-byte ticks and 24 bytes of values." + } + }, + "follow": { + "operation": "A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 52, + "basis": "Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12." + } + }, + "emit": { + "operation": "A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 34, + "basis": "Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes." + } + }, + "reduce": { + "operation": "A kernel folding every row into a sum and a maximum: totals, bounds, counts.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 1000000 + ], + "best": { + "bytes": 4, + "basis": "Each row reads its 4-byte value." + } + }, + "chain": { + "operation": "Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps.", + "per": "row passes", + "n": 6000000, + "typical": [ + 6000, + 6000000 + ], + "best": { + "bytes": 4, + "basis": "Fused into one pass, each row reads and writes its 12-byte velocity once for all six." + } + }, + "up depth 10": { + "operation": "A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." + } + }, + "up depth 1000": { + "operation": "A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." + } + }, + "up one root changed": { + "operation": "A filtered kernel with `up` after one root's value changed: moving one object in a scene graph.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 0.0078125, + "basis": "Chunk ticks are scanned; the 10 rows of the changed chain run." + } + }, + "up after a spawn": { + "operation": "A kernel with `up` computing every row, after a spawn into its tree: a node added to a scene graph each frame.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "The run itself, with the order kept current at O(1) for the spawn." + } + }, + "up filtered after churn": { + "operation": "A filtered kernel with `up` after one root changed and 100 leaves were despawned and 100 spawned in the tree's archetype: a scene graph with objects coming and going.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 0.0136925, + "basis": "Chunk ticks are scanned; the changed chain and the 200 leaves run; the order is patched for 200 rows." + } + }, + "up filtered after tagged churn": { + "operation": "A filtered kernel with `up` after one root changed and 100 leaves were despawned and 100 spawned, the leaves in an archetype of their own: a scene graph with short-lived children of their own kind, such as effects.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 0.0136925, + "basis": "Chunk ticks are scanned; the changed chain and the 200 leaves run; the order is patched for 200 rows." + } + }, + "up filtered after a reparent": { + "operation": "A filtered kernel with `up` after a subtree of 9 rows moved to another parent: dragging an object to a new group.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 0.0078125, + "basis": "Chunk ticks are scanned; the 9 moved rows are renumbered and run." + } + }, + "up load": { + "operation": "Spawning a forest of 100K chains of 10 and running a kernel with `up` over it once: loading a scene.", + "per": "spawns", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 68, + "basis": "Each entity writes 24 bytes of values, its entity and its location, then runs once." + } + }, + "up small tree in a big world": { + "operation": "A kernel with `up` over a tree of 1K rows after a spawn into it, in a world of 1M other entities: a UI or a rig beside a large simulation.", + "per": "tree rows", + "n": 1000, + "typical": [ + 100, + 10000 + ], + "best": { + "bytes": 28, + "basis": "The run over the tree, with the order kept current at O(1) for the spawn." + } + }, + "main thread": { + "operation": "Fifty main-thread systems each reading every row of 1K: many small app systems.", + "per": "row reads", + "n": 50000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 12, + "basis": "Each row read loads its 12-byte position." + } + }, + "select": { + "operation": "The first value of `observe.select` with `where`, given while subscribing: a query from UI or tools.", + "per": "rows", + "n": 1000000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 8, + "basis": "Each row reads its 4-byte index; each selected half writes its 8-byte entity." + } + }, + "move main": { + "operation": "A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update.", + "per": "matched rows", + "n": 250000, + "typical": [ + 1000, + 1000000 + ], + "best": { + "bytes": 36, + "basis": "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position." + } + }, + "follow main": { + "operation": "A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 52, + "basis": "Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12." + } + }, + "chain main": { + "operation": "Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps.", + "per": "row passes", + "n": 6000000, + "typical": [ + 6000, + 6000000 + ], + "best": { + "bytes": 4, + "basis": "Fused into one pass, each row reads and writes its 12-byte velocity once for all six." + } + }, + "sparse changed main": { + "operation": "A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved.", + "per": "rows in the archetype", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 8.24, + "basis": "Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12." + } + }, + "up depth 10 main": { + "operation": "A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." + } + }, + "up depth 1000 main": { + "operation": "A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." + } + }, + "move workers": { + "operation": "A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update.", + "per": "matched rows", + "n": 250000, + "typical": [ + 1000, + 1000000 + ], + "best": { + "bytes": 36, + "basis": "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position.", + "on": "threads" + } + }, + "follow workers": { + "operation": "A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 52, + "basis": "Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12.", + "on": "threads" + } + }, + "chain workers": { + "operation": "Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps.", + "per": "row passes", + "n": 6000000, + "typical": [ + 6000, + 6000000 + ], + "best": { + "bytes": 4, + "basis": "Fused into one pass, each row reads and writes its 12-byte velocity once for all six.", + "on": "threads" + } + }, + "sparse changed workers": { + "operation": "A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved.", + "per": "rows in the archetype", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 8.24, + "basis": "Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12.", + "on": "threads" + } + }, + "up depth 10 workers": { + "operation": "A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result.", + "on": "threads" + } + }, + "up depth 1000 workers": { + "operation": "A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result.", + "on": "threads" + } + }, + "up depth 10 gpu": { + "operation": "A kernel with `up` computing every row from its parent, over 100K chains of 10: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each row reads its 4-byte value, its parent's 4-byte result and 4-byte row, and writes its 4-byte result.", + "on": "gpu" + } + }, + "up depth 1000 gpu": { + "operation": "A kernel with `up` computing every row from its parent, over 1K chains of 1,000: transforms, inherited visibility or style, recomputed in full.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each row reads its 4-byte value, its parent's 4-byte result and 4-byte row, and writes its 4-byte result.", + "on": "gpu" + } + }, + "run idle": { + "operation": "Running one system no entity matches, in a world of 10K entities: the fixed cost of every system in every frame.", + "per": "runs", + "n": 1, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A run reads at least one cache line of its system's state." + } + }, + "run 50 idle": { + "operation": "Running fifty systems no entity matches, as one composite: the fixed cost per system of a frame of many systems.", + "per": "systems", + "n": 50, + "typical": [ + 10, + 200 + ], + "best": { + "bytes": 64, + "basis": "A run reads at least one cache line of its system's state." + } + }, + "sync point one spawn": { + "operation": "Running a system that spawns one entity, so its run ends with a sync point that applies one command: the fixed cost of a structural change.", + "per": "runs", + "n": 1, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 128, + "basis": "The spawn writes its 28 bytes of values, its entity and its location, and the run reads its system's state." + } + }, + "move 1K": { + "operation": "A kernel adding a vec3 to another for the rows with two tags, at a size a typical system sees.", + "per": "matched rows", + "n": 1000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 36, + "basis": "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position." + } + }, + "move 10K": { + "operation": "A kernel adding a vec3 to another for the rows with two tags, at a size a typical system sees.", + "per": "matched rows", + "n": 10000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 36, + "basis": "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position." + } + }, + "main thread 1K": { + "operation": "One main-thread system reading every row: app logic over a typical number of entities.", + "per": "row reads", + "n": 1000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 12, + "basis": "Each row read loads its 12-byte position." + } + }, + "main thread 10K": { + "operation": "One main-thread system reading every row: app logic over a typical number of entities.", + "per": "row reads", + "n": 10000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 12, + "basis": "Each row read loads its 12-byte position." + } + }, + "up depth 10 10K": { + "operation": "A kernel with `up` computing every row from its parent, over 1K chains of 10: a scene graph of typical size.", + "per": "rows", + "n": 10000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." + } + }, + "spawn bulk": { + "operation": "A main-thread system spawning 100K entities of one archetype, applied by its sync point: loading many of one kind.", + "per": "spawns", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 24, + "basis": "Each entity writes its 8 bytes of values, its 8-byte entity and its 8-byte location." + } + }, + "spawn bulk kernel": { + "operation": "A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits.", + "per": "spawns", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location." + } + }, + "spawn singles": { + "operation": "A run spawning one entity of each of four archetypes: the fixed cost of a few spawns a frame, such as a click or a message.", + "per": "spawns", + "n": 4, + "typical": [ + 1, + 10 + ], + "best": { + "bytes": 40, + "basis": "Each entity writes its values, entity and location, and the run reads a cache line of its system's state." + } + }, + "spawn sixteen archetypes": { + "operation": "Spawns from value objects over sixteen archetypes, more component sets than spawns cache: entities of many kinds, from data.", + "per": "spawns", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 24, + "basis": "Each entity writes its 8 bytes of values, its 8-byte entity and its 8-byte location." + } + }, + "despawn bulk": { + "operation": "A main-thread system despawning 100K entities over four archetypes: unloading a level.", + "per": "despawns", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each despawn frees its 8-byte location and entity; the archetypes empty, so no row moves." + } + }, + "despawn bulk kernel": { + "operation": "The same despawns from a kernel.", + "per": "despawns", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 20, + "basis": "Each row reads its 4-byte index; each despawn frees its location and entity." + } + }, + "despawn one": { + "operation": "A run despawning one entity of 100K: the fixed cost of a despawn, such as one projectile hitting.", + "per": "despawns", + "n": 1, + "typical": [ + 1, + 10 + ], + "best": { + "bytes": 96, + "basis": "The despawn swaps the last row into its hole and frees its location, and the run reads its system's state." + } + }, + "add tag": { + "operation": "Adding a tag to 100K entities of one archetype: a state flag set on many at once.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "add tag kernel": { + "operation": "The same adds from a kernel.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 20, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "add tag from four archetypes": { + "operation": "Adding a tag to 100K entities over four archetypes, each moving to its own target.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "add value": { + "operation": "Adding a valued component to 100K entities of one archetype: attaching data such as a timer or a score.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 20, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "add several": { + "operation": "Adding a tag and two values at once to 100K entities: attaching a bundle of components.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 24, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "remove tag": { + "operation": "Removing a tag from 100K entities of one archetype.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "remove tag kernel": { + "operation": "The same removes from a kernel.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 20, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "remove tag from four archetypes": { + "operation": "Removing a tag from 100K entities over four archetypes.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "remove several": { + "operation": "Removing a tag and two values at once from 100K entities.", + "per": "entities moved", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." + } + }, + "add replace": { + "operation": "Adding a value 100K entities already have: values replaced and marked changed, with no archetype moves.", + "per": "entities", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 12, + "basis": "Each row reads its 4-byte value and writes 4 bytes, plus its 4-byte location read." + } + }, + "spawn bulk kernel main": { + "operation": "A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits.", + "per": "spawns", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location." + } + }, + "spawn bulk kernel workers": { + "operation": "A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits.", + "per": "spawns", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 28, + "basis": "Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location.", + "on": "threads" + } + }, + "loop tick idle": { + "operation": "A world's loop ticking with nothing changed, driven back to back with `always` set, counted by an observer system reading its clock: the fixed cost of every tick.", + "per": "ticks", + "n": 10000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A run reads at least one cache line of its system's state." + } + }, + "sync point grow 100 archetypes main": { + "operation": "A sync point where each of 100 archetypes grows, by one spawn into each: levels and bursts filling many kinds of entities at once.", + "per": "archetypes grown", + "n": 100, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "Each archetype that grows writes at least its layout record, a cache line." + } + }, + "sync point grow 100 archetypes workers": { + "operation": "A sync point where each of 100 archetypes grows, by one spawn into each, then a kernel over them, so workers take up the new layout.", + "per": "archetypes grown", + "n": 100, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "Each archetype that grows writes at least its layout record, a cache line.", + "on": "threads" + } + }, + "wake": { + "operation": "A task posted to a pool worker waiting in its window, which wakes, marks it done and wakes the world's thread: the fixed cost of every tier on workers.", + "per": "round trips", + "n": 10000, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 128, + "basis": "A round trip moves at least the two cache lines of the ring's posted and done words between cores." + } + }, + "emit main": { + "operation": "A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 34, + "basis": "Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes." + } + }, + "emit workers": { + "operation": "A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 34, + "basis": "Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes.", + "on": "threads" + } + }, + "remote run": { + "operation": "An empty transaction run from a joined thread, through its run slots and completed word: input from another thread, such as the DOM on main driving a world on a worker.", + "per": "runs", + "n": 1000, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 192, + "basis": "A run moves at least its slot's line and the posted and completed words' lines between cores." + } + }, + "mailbox resource": { + "operation": "Ticks that change one resource a joined thread observes, each writing its mailbox and waking it once: a UI on main showing a world's state.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A tick reads at least one cache line of its system's state." + } + }, + "mailbox select 10K": { + "operation": "Ticks that reverse the order of 10K entities a joined thread selects in order, each recomputing the selection and writing it into the mailbox: a sorted list shown on main.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 240000, + "basis": "Each tick writes 10K 8-byte values, reads them to sort, and writes 10K 8-byte entities." + } + }, + "log replay": { + "operation": "A joined thread replaying a sync point's move log: 10K entities holding its objects moving archetype, each leaving a row the last swaps into, as when a tag on DOM-backed entities toggles.", + "per": "moves", + "n": 10000, + "typical": [ + 1, + 10000 + ], + "best": { + "bytes": 128, + "basis": "Each move copies out and reads two 16-byte entries, and copies its object, ticks and entity twice, 32 bytes read and written each." + } + }, + "log rebuild 100K": { + "operation": "A joined thread rebuilding its object columns over 100K rows from the layout directory, as when it fell further behind than its log holds.", + "per": "rows", + "n": 100000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 60, + "basis": "Each row reads its 8-byte entity, 4-byte tag and 16 bytes of ticks, and writes its entity, object and ticks." + } + }, + "handoff": { + "operation": "Ticks of ten tiers alternating the world's thread and a joined worker, empty systems: five tasks posted and waited for each tick, as when systems that write a worker's objects sit between the world's.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 1280, + "basis": "Each of five handoffs moves at least the task's posted, done, payload and completion lines between cores." + } + }, + "fused tiers": { + "operation": "Ticks of ten consecutive tiers on a joined worker, empty systems, which run as one task: a worker filling objects whose systems follow one another.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 256, + "basis": "One task moves at least its posted, done, payload and completion lines between cores." + } + }, + "despawn objects 10K": { + "operation": "A sync point despawning 10K entities whose objects a joined worker fills, with a finalizer there reading the object and a vec3: releasing DOM nodes or GPU handles, which the world's thread doesn't wait for.", + "per": "despawns", + "n": 10000, + "typical": [ + 1, + 10000 + ], + "best": { + "bytes": 56, + "basis": "Each despawn frees its 8-byte location and entity, and writes its stop: its entity, archetype, row and tag, 20 bytes, and its 12-byte vec3, read and written." + } + }, + "tail prompt": { + "operation": "Ticks of a world system and a terminal system on a joined worker over 1,000 rows, which runs after the sync point and finishes before the next tick needs it: a DOM-writing system on main that keeps up.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 12256, + "basis": "One task moves its posted, done, payload and completion lines between cores, and the tail reads each row's 12-byte vec3." + } + }, + "tail late snapshot 10K": { + "operation": "A run writing what a late tail on a joined worker reads, 10,000 passing rows of a vec3 and a mat4: the copy of the tail's reads it makes first, as when main is busy when the next tick writes positions.", + "per": "rows copied", + "n": 10000, + "typical": [ + 100, + 10000 + ], + "best": { + "bytes": 172, + "basis": "Each row's 12-byte vec3 and 64-byte mat4 are read and written, and its position, archetype, row and entity, 20 bytes, written." + } + }, + "tail overlap": { + "operation": "Ticks writing every row's place, which the last tick's tail on a joined worker still reads, then working 2 ms, while the tail works about 2.5 ms over 10,000 rows of a place and a pose: main's DOM work and the world's work after its writer that together outlast a frame or a fixed step, though each fits. The writer copies the rows the tail hasn't read, so the tick and the tail overlap.", + "per": "ticks", + "n": 100, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 1520000, + "basis": "The world copies each row's 12-byte vec3 and 64-byte mat4 the tail hasn't read, and the tail reads them, beyond the work each side does." + } + }, + "tail overlap waiting": { + "operation": "Ticks writing every row's place, which the last tick's tail on a joined worker still reads, then working 2 ms, while the tail works about 2.5 ms over 10,000 rows of a place and a pose: main's DOM work and the world's work after its writer that together outlast a frame or a fixed step, though each fits. The writer waits for the tail to finish instead, the alternative `tail overlap` is measured against.", + "per": "ticks", + "n": 100, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 760000, + "basis": "The tail reads each row's 12-byte vec3 and 64-byte mat4, beyond the work each side does." + } + }, + "transfer": { + "operation": "Runs from a loader on a joined worker, each moving a 1 MB ArrayBuffer and a 1024 by 1024 ImageBitmap to the joined worker whose system takes them, which acknowledges each to the world: decoded textures or audio reaching the thread that uses them.", + "per": "transfers", + "n": 100, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 256, + "basis": "A transfer moves its values' handles in one message, the bytes staying where they are, and the run's slot and completion lines between cores." + } + }, + "read observe": { + "operation": "Reading a resource through an observer's first value, `Observe.toPromise(world.observe.resource(key))`: a value read from outside the world.", + "per": "reads", + "n": 1, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "A read loads at least one cache line of the resource's cell." + } + }, + "read run": { + "operation": "Reading a resource through a run of a new system that copies it out: a value read from outside the world through a system made for the read.", + "per": "reads", + "n": 1, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "A read loads at least one cache line of the resource's cell." + } + }, + "read run changed": { + "operation": "Reading the ranks that changed through a run of a new system filtering on `changed`, with a kernel writing them: a read whose filter turns the kernel's stamps on while the system lives.", + "per": "reads", + "n": 1, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "A read loads at least one cache line of a chunk's tick." + } + }, + "observe 1K entities": { + "operation": "A run that changes nothing with an entity observer on each of 1K entities: what watching entities costs each frame.", + "per": "observers", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each observer reads its entity's 8-byte location and its component's 8-byte changed tick." + } + }, + "observe 1K entities one changed": { + "operation": "A run that changes one of 1K entities, each with an entity observer: what watching entities costs each frame while one of them changes.", + "per": "observers", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each observer reads its entity's 8-byte location and its component's 8-byte changed tick." + } + }, + "select page 100K": { + "operation": "A run that changes one entity's order component under a `select` observer with `where`, `order` and a 20-row `page` over 100K entities: a sorted, filtered list of a UI, recomputed after a change.", + "per": "rows", + "n": 100000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each row reads its 8-byte rank and its 8-byte value." + } + }, + "loop tick add remove": { + "operation": "Ticks back to back, each adding a new system with a change filter over 100 rows and removing the one the last tick added: systems that come and go with what a UI shows.", + "per": "ticks", + "n": 10000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A tick reads at least one cache line of its systems' state." + } + }, + "loop tick add remove again": { + "operation": "Ticks back to back, each adding or removing one module's system with a change filter over 100 rows: a feature switched on and off.", + "per": "ticks", + "n": 10000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A tick reads at least one cache line of its systems' state." + } + }, + "loop tick idle after add remove": { + "operation": "Ticks with nothing changed after 60,000 ticks that each added a new system and removed the last: what systems that came and went leave behind in every tick.", + "per": "ticks", + "n": 10000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A tick reads at least one cache line of its systems' state." + } + }, + "index idle": { + "operation": "A run of a system looking up through 1,000 indexes when nothing they hold changed: what keeping an index costs each frame while its entities rest.", + "per": "indexes", + "n": 1000, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 16, + "basis": "Each index reads its journal position and its key's last write, 8 bytes each." + } + }, + "index refresh 1M 1%": { + "operation": "Refreshing a hash index of 1M entities after a system moved 1% of them to other keys: an index kept current as some entities change their key each frame.", + "per": "keys changed", + "n": 10000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 48, + "basis": "Each changed row reads its 8-byte key and tick, and moves a 12-byte entry out of one bucket and into another." + } + }, + "index rebuild 1M main": { + "operation": "Building a hash index of 1M entities again after a system moved every one of them to another key, on the world's thread: an index whose keys all change at once.", + "per": "entities", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 24, + "basis": "Each entity reads its 8-byte key and location and writes a 12-byte entry." + } + }, + "index rebuild 1M workers": { + "operation": "Building a hash index of 1M entities again after a system moved every one of them to another key, across the pool: an index whose keys all change at once.", + "per": "entities", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 24, + "basis": "Each entity reads its 8-byte key and location and writes a 12-byte entry.", + "on": "threads" + } + }, + "rekey 1M": { + "operation": "A frame of 1M particles drifting a quarter unit, a kernel writing each one's cell of 4 units only when it crosses into another, and the index on the cell refreshed for its reader: a computed key kept as a component.", + "per": "rows", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 36, + "basis": "Each row reads and writes its 12-byte position and reads its 12-byte cell; a crossing also moves its 12-byte entry." + } + }, + "rekey 1M unindexed": { + "operation": "The frame of `rekey 1M` without the index: the drift and the kernel computing each particle's cell, what the key costs before the index keeps it.", + "per": "rows", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 36, + "basis": "Each row reads and writes its 12-byte position and reads its 12-byte cell." + } + }, + "order run 100K": { + "operation": "A main-thread system running 100K rows in the order of a sorted index whose ranks are scattered through storage: views laid out by sibling rank, timelines in time order.", + "per": "rows", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 20, + "basis": "Each row reads its 12-byte entry in key order and its 8-byte value." + } + }, + "order run 100K storage order": { + "operation": "The system of `order run 100K` without `order`, its rows in storage order: what running the rows costs alone.", + "per": "rows", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 8, + "basis": "Each row reads its 8-byte value." + } + }, + "order filtered 100K 1%": { + "operation": "A main-thread system with a change filter running, in a sorted index's order, the 1% of 100K rows that changed since it last ran: reacting to edits in document order.", + "per": "rows changed", + "n": 1000, + "typical": [ + 10, + 10000 + ], + "best": { + "bytes": 40, + "basis": "Each changed row reads its tick, its entry's position and its 12-byte entry, and its 8-byte value." + } + }, + "unique lookup 1M": { + "operation": "1M lookups by key through a unique index, each reading one value of the row it finds, in a main-thread system: a replication map from server ids to entities.", + "per": "lookups", + "n": 1000000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 24, + "basis": "Each lookup reads its bucket's 8-byte slot and its 12-byte entry, and a 4-byte value." + } + }, + "children lookup": { + "operation": "Each of 10K parents summing a value of its ten children through an index on their parent reference, in a main-thread system: a reverse relationship.", + "per": "children", + "n": 100000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each child is a 12-byte entry and a 4-byte value read." + } + }, + "sorted range 1M": { + "operation": "10K lookups of a range of ten keys through a sorted index of 1M entities, each reading a value of its rows, in a main-thread system: timelines, intervals, nearest by order.", + "per": "range lookups", + "n": 10000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 192, + "basis": "Each lookup reads two lines of the blocks' first keys and ten 16-byte entries and their 4-byte values." + } + }, + "sorted page 100K": { + "operation": "A run that changes one entity's value, and a system reading the 20 rows after the first 100 with a small key, highest values first, through a sorted index on the value, recomputed when it changed: `select page 100K` as a system reading an index.", + "per": "rows", + "n": 100000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each row reads its 8-byte rank and its 8-byte value, as `select page 100K`'s rows do." + } + }, + "grid neighbors 100K main": { + "operation": "Each of 100K particles reading the positions of those in the 27 cells around its own through a hash index on its cell, in a main-thread system: a uniform grid broadphase.", + "per": "rows", + "n": 100000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 1000, + "basis": "Each row probes 27 buckets, a 64-byte line each, and reads about 270 neighbors' 12-byte entries and positions." + } + }, + "grid neighbors 100K workers": { + "operation": "Each of 100K particles reading the positions of those in the 27 cells around its own through a hash index on its cell, in a kernel across the pool: a uniform grid broadphase.", + "per": "rows", + "n": 100000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 1000, + "basis": "Each row probes 27 buckets, a 64-byte line each, and reads about 270 neighbors' 12-byte entries and positions.", + "on": "threads" + } + }, + "run observer": { + "operation": "Running one observer system no entity matches, in a world of 10K entities: a run with no tiers, its sync point empty, against `run idle`.", + "per": "runs", + "n": 1, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A run reads at least one cache line of its system's state." + } + }, + "observer idle 100": { + "operation": "Ticks as in `loop tick idle`, with a hundred observer systems no entity matches added, each run after every tick: the cost per observer system per pass, a hundredth of the tick's own cost included.", + "per": "observer systems", + "n": 1000000, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "A run reads at least one cache line of its system's state." + } + }, + "joined observer resource": { + "operation": "Ticks that change one resource an observer system a joined thread added reads after each sync point, posted to it and waking it once.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A tick reads at least one cache line of its system's state." + } + }, + "joined observer 10K": { + "operation": "Ticks that reverse the order of 10K entities an observer system a joined thread added reads through an index and sorts after each sync point.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 240000, + "basis": "Each tick writes 10K 8-byte values, reads them to sort, and sorts 10K 8-byte entities." + } + }, + "joined resource factory": { + "operation": "Ticks that change one resource a joined thread observes through `Observe.resource`, an observer system it added, posted to it and waking it once.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A tick reads at least one cache line of its system's state." + } + }, + "joined select 10K factory": { + "operation": "Ticks that reverse the order of 10K entities a joined thread selects in order through `Observe.select`.", + "per": "ticks", + "n": 1000, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 240000, + "basis": "Each tick writes 10K 8-byte values, reorders them in the index, and reads 10K 8-byte entities." + } + }, + "joined resource factory cadence": { + "operation": "`joined resource factory` at a display's cadence, frames 2 ms apart, the joined thread keeping up: what the world's thread spends on a tick, from its frame until the tick is done.", + "per": "ticks", + "n": 100, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 64, + "basis": "A tick reads at least one cache line of its system's state." + } + }, + "read run observer": { + "operation": "Reading a resource through a run of a new observer system that copies it out, which has no tiers and an empty sync point: as `read run`, through an observer system.", + "per": "reads", + "n": 1, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "A read loads at least one cache line of the resource's cell." + } + }, + "lookup 1K entities": { + "operation": "A system looking up 1K entities by entity, reading a value of each, with nothing changed: following references, without `changed`.", + "per": "lookups", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each lookup reads its entity's 8-byte location and its 8-byte value." + } + }, + "lookup 1K entities changed": { + "operation": "The same with `changed`, with nothing changed: watching entities a system follows for changes since it last ran.", + "per": "lookups", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 24, + "basis": "Each lookup reads its entity's 8-byte location, its 8-byte value and its 8-byte changed tick." + } + }, + "lookup 1K entities one changed": { + "operation": "A run writing one of 1K entities, then a system looking up each by entity, reading its value, without `changed`.", + "per": "lookups", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each lookup reads its entity's 8-byte location and its 8-byte value." + } + }, + "lookup 1K entities changed one changed": { + "operation": "The same with `changed`: one of the entities a system follows changed since it last ran.", + "per": "lookups", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 24, + "basis": "Each lookup reads its entity's 8-byte location, its 8-byte value and its 8-byte changed tick." + } + }, + "read observe factory warm": { + "operation": "Reading a resource through an observer factory's first value, `Observe.toPromise(Observe.resource(world, key))`, while the factories hold its value: a value read from outside the world, again.", + "per": "reads", + "n": 1, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "A read loads at least one cache line of the resource's cell." + } + }, + "read observe factory cold": { + "operation": "Reading a resource through an observer factory's first value while the factories hold nothing of it, their observer system for it removed after idle passes: a run of observer systems for one read.", + "per": "reads", + "n": 1, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 64, + "basis": "A read loads at least one cache line of the resource's cell." + } + }, + "observe 1K entities factory": { + "operation": "A run that changes nothing with an entity observer on each of 1K entities, through the observer factories: what watching entities costs each frame.", + "per": "observers", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each observer reads its entity's 8-byte location and its component's 8-byte changed tick." + } + }, + "observe 1K entities one changed factory": { + "operation": "A run that changes one of 1K entities, each with an entity observer, through the observer factories: what watching entities costs each frame while one of them changes.", + "per": "observers", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each observer reads its entity's 8-byte location and its component's 8-byte changed tick." + } + }, + "observe one entity among 100K moving factory": { + "operation": "A kernel writing the rank of 100K entities, one of them observed, through the observer factories: a debugging view of one entity in a moving world, beyond the kernel's own time.", + "per": "runs", + "n": 1, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 800000, + "basis": "The kernel reads and writes 100K 4-byte ranks; observing one entity adds a cache line." + } + }, + "observe subscribe churn 1K factory": { + "operation": "Subscribing an entity observer to each of 1K entities, a run, and stopping them all, through the observer factories: views opened and closed each frame.", + "per": "observers", + "n": 1000, + "typical": [ + 10, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each observer reads its entity's 8-byte location and its component's 8-byte value." + } + }, + "select page 100K factory": { + "operation": "A run that changes one entity's order component under an observer factory's `select` with `where`, `order` and a 20-row `page` over 100K entities: a sorted, filtered list of a UI, kept current through a sorted index.", + "per": "rows", + "n": 100000, + "typical": [ + 1000, + 100000 + ], + "best": { + "bytes": 16, + "basis": "Each row reads its 8-byte rank and its 8-byte value." + } + }, + "select page 100K churn factory": { + "operation": "The first value of a 20-row page of a `select` with `where` and `order` over 100K entities, at another offset each time, while another observer of that query keeps it current, through the observer factories: a UI paging.", + "per": "reads", + "n": 1, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 320, + "basis": "A page reads 20 entries of 16 bytes: an entity, its rank and its value." + } + }, + "select factory": { + "operation": "The first value of an observer factory's `select` with `where` over 1M entities, which the factories hold nothing of: one scan, a run of an observer system over the rows that match.", + "per": "rows", + "n": 1000000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 8, + "basis": "Each row reads its 4-byte rank; each selected half writes its 8-byte entity." + } + }, + "count 1M changed factory": { + "operation": "A run that changes one entity's value under a `count` with `where` over 1M entities, through the observer factories: a filtered total of a UI, counted again after a change.", + "per": "rows", + "n": 1000000, + "typical": [ + 1000, + 1000000 + ], + "best": { + "bytes": 8, + "basis": "Each row reads its 8-byte value." + } + }, + "keyless index idle": { + "operation": "A run of a system looking up through 1,000 indexes without a key when nothing they hold changed: what keeping a membership costs each frame while its entities rest.", + "per": "indexes", + "n": 1000, + "typical": [ + 1, + 100 + ], + "best": { + "bytes": 8, + "basis": "Each index reads its journal position, 8 bytes." + } + }, + "keyless refresh 1M 1%": { + "operation": "Refreshing an index without a key of 1M entities after 1% of them, spread through entity order, left it or entered it again at a sync point: a membership kept current as some entities gain or lose a tag each frame.", + "per": "entities entering or leaving", + "n": 10000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 24, + "basis": "Each entity reads its 4-byte journal entry and its 8-byte location, and moves a 12-byte entry in or out." + } + }, + "keyless spawn 1M 1%": { + "operation": "Refreshing an index without a key of 1M entities after a sync point despawned the 1% spawned the frame before and spawned as many again: a membership of entities that come and go in bursts, as particles do.", + "per": "entities spawned or despawned", + "n": 20000, + "typical": [ + 100, + 100000 + ], + "best": { + "bytes": 24, + "basis": "Each entity reads its 4-byte journal entry and its 8-byte location, and moves a 12-byte entry in or out." + } + }, + "keyless write 1M": { + "operation": "Refreshing an index without a key of 1M entities after a kernel wrote a component every one of them has, which the index holds by but no lookup watches: a membership costs nothing for the values its entities write each frame.", + "per": "rows written", + "n": 1000000, + "typical": [ + 10000, + 1000000 + ], + "best": { + "bytes": 0.001, + "basis": "Nothing it holds changed: the index reads its journal position and nothing per row." + } + }, + "keyless length": { + "operation": "100K lookups through an index without a key of 1M entities, each reading its length and whether it changed, in a main-thread system: a count a system keeps.", + "per": "lookups", + "n": 100000, + "typical": [ + 1, + 1000 + ], + "best": { + "bytes": 16, + "basis": "Each lookup reads its length and its range's newest tick, 8 bytes each." + } + }, + "observe gpu entity factory": { + "operation": "A GPU kernel writing a value of 100K entities, one entity of which is observed through the observer factories: each run reads that storage back for it.", + "per": "runs", + "n": 1, + "typical": [ + 1, + 1 + ], + "best": { + "bytes": 400000, + "basis": "The readback copies the observed storage's 100K 4-byte values.", + "on": "gpu" + } + } +} diff --git a/packages/ecs/perf/history/apple-m1-max-10-cores-32gb-darwin-arm64.jsonl b/packages/ecs/perf/history/apple-m1-max-10-cores-32gb-darwin-arm64.jsonl new file mode 100644 index 000000000..d5be370eb --- /dev/null +++ b/packages/ecs/perf/history/apple-m1-max-10-cores-32gb-darwin-arm64.jsonl @@ -0,0 +1,60 @@ +{"commit":"c2c445f8","dirty":false,"date":"2026-10-16T20:12:46.253Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,120.67671453836464],[2097152,112.95021842292775],[8388608,69.2557936016511],[33554432,66.1089658909003],[134217728,89.47848533333334]]},"results":{"create":{"milliseconds":93.76637500000015,"n":1000000},"create flat":{"milliseconds":70.38891700000022,"n":1000000},"create flat dynamic":{"milliseconds":224.06533299999865,"n":1000000},"move":{"milliseconds":0.09162500000093132,"n":250000},"churn":{"milliseconds":19.042833999999857,"n":1000000},"retag":{"milliseconds":41.58829100000003,"n":1000000},"sparse changed":{"milliseconds":1.2524169999996957,"n":1000000},"sparse changed clustered":{"milliseconds":0.021040999999968335,"n":1000000},"follow":{"milliseconds":3.8722079999970447,"n":1000000},"emit":{"milliseconds":10.158959000000323,"n":1000000},"reduce":{"milliseconds":0.9337080000004789,"n":1000000},"chain":{"milliseconds":1.6041669999976875,"n":6000000},"up depth 10":{"milliseconds":2.052416999998968,"n":1000000},"up depth 1000":{"milliseconds":2.4046660000021802,"n":1000000},"up one root changed":{"milliseconds":0.029624999999214197,"n":1000000},"up after a spawn":{"milliseconds":36.71770800000013,"n":1000000},"up filtered after churn":{"milliseconds":50.07220800000505,"n":1000000},"up filtered after tagged churn":{"milliseconds":49.388208999997005,"n":1000000},"up filtered after a reparent":{"milliseconds":34.48570800000016,"n":1000000},"up load":{"milliseconds":413.08791600000404,"n":1000000},"up small tree in a big world":{"milliseconds":2.313709000001836,"n":1000},"main thread":{"milliseconds":0.4937920000011218,"n":50000},"select":{"milliseconds":28.039707999996608,"n":1000000}}} +{"commit":"c2c445f8","dirty":false,"date":"2026-10-16T20:13:06.528Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 128.0.6613.18","bandwidth":{"thread":[[524288,118.77675094369175],[2097152,106.52200715545479],[8388608,63.610297199074594],[33554432,89.77774419525508],[134217728,79.65444068715944]],"threads":[[524288,675.3093597246964],[2097152,235.46970120036937],[8388608,234.44143798935977],[33554432,234.4414501935509],[134217728,188.37575702062173]],"gpu":324.7857912457469},"results":{"move main":{"milliseconds":0.09999990463256836,"n":250000},"follow main":{"milliseconds":4.565000057220459,"n":1000000},"chain main":{"milliseconds":1.7899999618530273,"n":6000000},"sparse changed main":{"milliseconds":1.4799998998641968,"n":1000000},"up depth 10 main":{"milliseconds":2.440000057220459,"n":1000000},"up depth 1000 main":{"milliseconds":2.7300000190734863,"n":1000000},"move workers":{"milliseconds":0.019999980926513672,"n":250000},"follow workers":{"milliseconds":0.6949999332427979,"n":1000000},"chain workers":{"milliseconds":0.38499999046325684,"n":6000000},"sparse changed workers":{"milliseconds":0.315000057220459,"n":1000000},"up depth 10 workers":{"milliseconds":0.3999999761581421,"n":1000000},"up depth 1000 workers":{"milliseconds":2.7350000143051147,"n":1000000},"up depth 10 gpu":{"milliseconds":0.2805000007152557,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.469999998807907,"n":1000000}}} +{"commit":"c768c2e8","dirty":false,"date":"2026-10-16T23:26:27.251Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,120.91229701218512],[2097152,106.6843189201343],[8388608,63.51622738834674],[33554432,73.58762926163878],[134217728,94.30369084841061]]},"results":{"create":{"milliseconds":85.87545800000044,"n":1000000},"create flat":{"milliseconds":67.43329199999971,"n":1000000},"create flat dynamic":{"milliseconds":225.39720800000032,"n":1000000},"move":{"milliseconds":0.09141699999963748,"n":250000},"churn":{"milliseconds":19.413875000002008,"n":1000000},"retag":{"milliseconds":43.74958300000071,"n":1000000},"sparse changed":{"milliseconds":1.123875000001135,"n":1000000},"sparse changed clustered":{"milliseconds":0.023708000000624452,"n":1000000},"follow":{"milliseconds":3.85137499999837,"n":1000000},"emit":{"milliseconds":10.38575000000128,"n":1000000},"reduce":{"milliseconds":0.9335419999988517,"n":1000000},"chain":{"milliseconds":1.5968749999992724,"n":6000000},"up depth 10":{"milliseconds":2.061375000001135,"n":1000000},"up depth 1000":{"milliseconds":2.4306670000005397,"n":1000000},"up one root changed":{"milliseconds":0.030374999998457497,"n":1000000},"up after a spawn":{"milliseconds":25.32666700000118,"n":1000000},"up filtered after churn":{"milliseconds":0.05812499999956344,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.09054200000173296,"n":1000000},"up filtered after a reparent":{"milliseconds":0.02204199999687262,"n":1000000},"up load":{"milliseconds":462.50333300000057,"n":1000000},"up small tree in a big world":{"milliseconds":0.27995799999916926,"n":1000},"main thread":{"milliseconds":0.5021249999990687,"n":50000},"select":{"milliseconds":27.819582999996783,"n":1000000}}} +{"commit":"f9722dff","dirty":false,"date":"2026-10-16T23:30:21.410Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,117.36165963493278],[2097152,109.96189365090788],[8388608,71.75661148232147],[33554432,67.4969715866235],[134217728,91.6683818638047]]},"results":{"create":{"milliseconds":85.47670800000014,"n":1000000},"create flat":{"milliseconds":69.5888329999998,"n":1000000},"create flat dynamic":{"milliseconds":223.31883299999936,"n":1000000},"move":{"milliseconds":0.09291600000142353,"n":250000},"churn":{"milliseconds":19.54479100000026,"n":1000000},"retag":{"milliseconds":44.522375000000466,"n":1000000},"sparse changed":{"milliseconds":1.1225410000006377,"n":1000000},"sparse changed clustered":{"milliseconds":0.02387500000259024,"n":1000000},"follow":{"milliseconds":3.892583000000741,"n":1000000},"emit":{"milliseconds":10.564875000000029,"n":1000000},"reduce":{"milliseconds":0.9513330000008864,"n":1000000},"chain":{"milliseconds":1.6051250000018626,"n":6000000},"up depth 10":{"milliseconds":2.058000000000902,"n":1000000},"up depth 1000":{"milliseconds":2.443250000000262,"n":1000000},"up one root changed":{"milliseconds":0.02449999999953434,"n":1000000},"up after a spawn":{"milliseconds":19.419957999998587,"n":1000000},"up filtered after churn":{"milliseconds":0.06454200000007404,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.0921249999992142,"n":1000000},"up filtered after a reparent":{"milliseconds":0.021709000000555534,"n":1000000},"up load":{"milliseconds":448.69550000000163,"n":1000000},"up small tree in a big world":{"milliseconds":0.2729579999941052,"n":1000},"main thread":{"milliseconds":0.5051249999960419,"n":50000},"select":{"milliseconds":31.379500000002736,"n":1000000}}} +{"commit":"948d52e0","dirty":false,"date":"2026-10-16T23:36:14.462Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,119.58815075383177],[2097152,121.89610410841011],[8388608,94.2622969010616],[33554432,91.36934343936309],[134217728,87.8843798618131]]},"results":{"create":{"milliseconds":93.94104200000038,"n":1000000},"create flat":{"milliseconds":70.76454100000001,"n":1000000},"create flat dynamic":{"milliseconds":229.0637499999998,"n":1000000},"move":{"milliseconds":0.09149999999999636,"n":250000},"churn":{"milliseconds":19.60575000000017,"n":1000000},"retag":{"milliseconds":36.127500000000055,"n":1000000},"sparse changed":{"milliseconds":1.1662079999999833,"n":1000000},"sparse changed clustered":{"milliseconds":0.02591699999999264,"n":1000000},"follow":{"milliseconds":3.8535420000000045,"n":1000000},"emit":{"milliseconds":9.048625000000015,"n":1000000},"reduce":{"milliseconds":0.9345420000000786,"n":1000000},"chain":{"milliseconds":1.599790999999982,"n":6000000},"up depth 10":{"milliseconds":2.101749999999811,"n":1000000},"up depth 1000":{"milliseconds":2.405665999999883,"n":1000000},"up one root changed":{"milliseconds":0.021250000000009095,"n":1000000},"up after a spawn":{"milliseconds":17.4240420000001,"n":1000000},"up filtered after churn":{"milliseconds":0.04091700000003584,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.035750000000007276,"n":1000000},"up filtered after a reparent":{"milliseconds":0.017708999999968,"n":1000000},"up load":{"milliseconds":430.4233750000001,"n":1000000},"up small tree in a big world":{"milliseconds":0.03387499999999477,"n":1000},"main thread":{"milliseconds":0.4743750000000091,"n":50000},"select":{"milliseconds":23.566625000000045,"n":1000000}}} +{"commit":"bbe6a115","dirty":false,"date":"2026-10-16T23:38:10.355Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 128.0.6613.18","bandwidth":{"thread":[[524288,118.77675094369175],[2097152,112.31609665096934],[8388608,69.72349247427907],[33554432,82.34216465806014],[134217728,96.55951754049998]],"threads":[[524288,675.3093597246964],[2097152,244.03222743801663],[8388608,234.95445069691505],[33554432,233.42214049149348],[134217728,209.30639377130296]],"gpu":328.562371370914},"results":{"move main":{"milliseconds":0.09999990463256836,"n":250000},"follow main":{"milliseconds":4.584999918937683,"n":1000000},"chain main":{"milliseconds":1.7350000143051147,"n":6000000},"sparse changed main":{"milliseconds":1.0950000286102295,"n":1000000},"up depth 10 main":{"milliseconds":2.2149999141693115,"n":1000000},"up depth 1000 main":{"milliseconds":2.565000057220459,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.7300000190734863,"n":1000000},"chain workers":{"milliseconds":0.28999996185302734,"n":6000000},"sparse changed workers":{"milliseconds":0.18000006675720215,"n":1000000},"up depth 10 workers":{"milliseconds":0.36000001430511475,"n":1000000},"up depth 1000 workers":{"milliseconds":2.6049998998641968,"n":1000000},"up depth 10 gpu":{"milliseconds":0.24675000309944153,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.459249997138977,"n":1000000}}} +{"commit":"43b7e59c","dirty":false,"date":"2026-10-17T00:03:19.112Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,116.18067777537341],[2097152,95.86412234131868],[8388608,62.98344814641013],[33554432,82.45598402703159],[134217728,96.29398069926593]]},"results":{"create":{"milliseconds":90.20333299999947,"n":1000000},"create flat":{"milliseconds":70.08533299999999,"n":1000000},"create flat dynamic":{"milliseconds":226.31445799999983,"n":1000000},"move":{"milliseconds":0.09162500000002183,"n":250000},"churn":{"milliseconds":20.576999999999998,"n":1000000},"retag":{"milliseconds":36.03800000000001,"n":1000000},"sparse changed":{"milliseconds":1.1362080000000105,"n":1000000},"sparse changed clustered":{"milliseconds":0.02254100000001813,"n":1000000},"follow":{"milliseconds":3.8909999999999627,"n":1000000},"emit":{"milliseconds":8.856832999999938,"n":1000000},"reduce":{"milliseconds":0.933708999999908,"n":1000000},"chain":{"milliseconds":1.5999589999999841,"n":6000000},"up depth 10":{"milliseconds":2.319250000000011,"n":1000000},"up depth 1000":{"milliseconds":2.409208999999919,"n":1000000},"up one root changed":{"milliseconds":0.0183750000001055,"n":1000000},"up after a spawn":{"milliseconds":2.5237090000000535,"n":1000000},"up filtered after churn":{"milliseconds":0.04187500000000455,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.036665999999968335,"n":1000000},"up filtered after a reparent":{"milliseconds":0.022040999999944688,"n":1000000},"up load":{"milliseconds":455.9019579999999,"n":1000000},"up small tree in a big world":{"milliseconds":0.01491699999999696,"n":1000},"main thread":{"milliseconds":0.47525000000007367,"n":50000},"select":{"milliseconds":26.31404099999986,"n":1000000}}} +{"commit":"cfcff49a","dirty":false,"date":"2026-10-17T00:13:57.940Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,106.59247581933153],[2097152,103.3802318738771],[8388608,60.81915940173081],[33554432,69.41999445540971],[134217728,97.78770194281779]]},"results":{"create":{"milliseconds":87.43220799999972,"n":1000000},"create flat":{"milliseconds":70.47904199999994,"n":1000000},"create flat dynamic":{"milliseconds":226.1083749999998,"n":1000000},"move":{"milliseconds":0.0913749999999709,"n":250000},"churn":{"milliseconds":20.02358300000003,"n":1000000},"retag":{"milliseconds":35.49437499999999,"n":1000000},"sparse changed":{"milliseconds":1.1355419999999867,"n":1000000},"sparse changed clustered":{"milliseconds":0.022584000000023252,"n":1000000},"follow":{"milliseconds":3.839417000000026,"n":1000000},"emit":{"milliseconds":8.694374999999923,"n":1000000},"reduce":{"milliseconds":0.505416000000082,"n":1000000},"chain":{"milliseconds":1.5721669999999222,"n":6000000},"up depth 10":{"milliseconds":2.046624999999949,"n":1000000},"up depth 1000":{"milliseconds":2.368624999999838,"n":1000000},"up one root changed":{"milliseconds":0.023250000000075488,"n":1000000},"up after a spawn":{"milliseconds":2.0956669999999917,"n":1000000},"up filtered after churn":{"milliseconds":0.04679199999998218,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04016699999999673,"n":1000000},"up filtered after a reparent":{"milliseconds":0.02174999999999727,"n":1000000},"up load":{"milliseconds":215.762333,"n":1000000},"up small tree in a big world":{"milliseconds":0.0116249999999809,"n":1000},"main thread":{"milliseconds":0.47604199999999963,"n":50000},"select":{"milliseconds":24.30320800000004,"n":1000000}}} +{"commit":"c234c41a","dirty":false,"date":"2026-10-17T00:40:35.498Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,119.12379072483724],[2097152,109.98072559664],[8388608,74.02902304524677],[33554432,79.58949106811957],[134217728,81.17390754775303]]},"results":{"create":{"milliseconds":90.1436249999997,"n":1000000},"create flat":{"milliseconds":70.91162500000019,"n":1000000},"create flat dynamic":{"milliseconds":224.51658300000054,"n":1000000},"move":{"milliseconds":0.09141599999998107,"n":250000},"churn":{"milliseconds":19.778583000000026,"n":1000000},"retag":{"milliseconds":35.546291,"n":1000000},"sparse changed":{"milliseconds":0.3475419999999758,"n":1000000},"sparse changed clustered":{"milliseconds":0.025542000000001508,"n":1000000},"follow":{"milliseconds":3.8905409999999847,"n":1000000},"emit":{"milliseconds":8.811583000000041,"n":1000000},"reduce":{"milliseconds":0.5143749999999727,"n":1000000},"chain":{"milliseconds":1.5732920000000377,"n":6000000},"up depth 10":{"milliseconds":2.072541999999885,"n":1000000},"up depth 1000":{"milliseconds":2.4229589999999916,"n":1000000},"up one root changed":{"milliseconds":0.025417000000061307,"n":1000000},"up after a spawn":{"milliseconds":2.075833999999986,"n":1000000},"up filtered after churn":{"milliseconds":0.04695800000001782,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.0399169999999458,"n":1000000},"up filtered after a reparent":{"milliseconds":0.02208300000000918,"n":1000000},"up load":{"milliseconds":217.19225,"n":1000000},"up small tree in a big world":{"milliseconds":0.01170799999999872,"n":1000},"main thread":{"milliseconds":0.47504200000000196,"n":50000},"select":{"milliseconds":22.37854100000004,"n":1000000}}} +{"commit":"4cb07209","dirty":false,"date":"2026-10-17T00:42:02.420Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 128.0.6613.18","bandwidth":{"thread":[[524288,119.30464711111111],[2097152,110.01452856758942],[8388608,65.95465857566302],[33554432,82.59552492307692],[134217728,89.47848533333334]],"threads":[[524288,635.3501702041252],[2097152,239.1407175321551],[8388608,240.2106973439635],[33554432,235.98721653906534],[134217728,202.97577159601346]],"gpu":326.3652998327806},"results":{"move main":{"milliseconds":0.09999990463256836,"n":250000},"follow main":{"milliseconds":4.730000019073486,"n":1000000},"chain main":{"milliseconds":1.7549999952316284,"n":6000000},"sparse changed main":{"milliseconds":0.6950000524520874,"n":1000000},"up depth 10 main":{"milliseconds":2.325000047683716,"n":1000000},"up depth 1000 main":{"milliseconds":2.684999942779541,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.690000057220459,"n":1000000},"chain workers":{"milliseconds":0.2950000762939453,"n":6000000},"sparse changed workers":{"milliseconds":0.15999996662139893,"n":1000000},"up depth 10 workers":{"milliseconds":0.39499998092651367,"n":1000000},"up depth 1000 workers":{"milliseconds":2.9499999284744263,"n":1000000},"up depth 10 gpu":{"milliseconds":0.28700000047683716,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.531999999284745,"n":1000000}}} +{"commit":"9d237946","dirty":false,"date":"2026-10-17T00:53:35.696Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,124.1989697103735],[2097152,111.91805545132381],[8388608,74.27141343293908],[33554432,74.03584251534143],[134217728,85.75752355784105]]},"results":{"run idle":{"milliseconds":0.0007909999999355932,"n":1},"run 50 idle":{"milliseconds":0.019457999999985987,"n":50},"sync point one spawn":{"milliseconds":0.0014999999999645297,"n":1},"move 1K":{"milliseconds":0.0014999999999645297,"n":1000},"move 10K":{"milliseconds":0.00533300000000736,"n":10000},"main thread 1K":{"milliseconds":0.009457999999995081,"n":1000},"main thread 10K":{"milliseconds":0.08545800000001691,"n":10000},"up depth 10 10K":{"milliseconds":0.01920800000004874,"n":10000},"create":{"milliseconds":91.71979200000033,"n":1000000},"create flat":{"milliseconds":69.7524999999996,"n":1000000},"create flat dynamic":{"milliseconds":226.45574999999917,"n":1000000},"move":{"milliseconds":0.09291700000005676,"n":250000},"churn":{"milliseconds":20.333624999999984,"n":1000000},"retag":{"milliseconds":35.56620799999996,"n":1000000},"sparse changed":{"milliseconds":0.35183399999999665,"n":1000000},"sparse changed clustered":{"milliseconds":0.025834000000003243,"n":1000000},"follow":{"milliseconds":3.844000000000051,"n":1000000},"emit":{"milliseconds":8.797167000000002,"n":1000000},"reduce":{"milliseconds":0.50487499999997,"n":1000000},"chain":{"milliseconds":1.5692080000000033,"n":6000000},"up depth 10":{"milliseconds":2.0748329999998987,"n":1000000},"up depth 1000":{"milliseconds":2.4203750000000355,"n":1000000},"up one root changed":{"milliseconds":0.022667000000069493,"n":1000000},"up after a spawn":{"milliseconds":2.0594169999999963,"n":1000000},"up filtered after churn":{"milliseconds":0.04241700000000037,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.03899999999998727,"n":1000000},"up filtered after a reparent":{"milliseconds":0.02179200000000492,"n":1000000},"up load":{"milliseconds":218.26420800000002,"n":1000000},"up small tree in a big world":{"milliseconds":0.012874999999979764,"n":1000},"main thread":{"milliseconds":0.4774589999999961,"n":50000},"select":{"milliseconds":22.146124999999984,"n":1000000}}} +{"commit":"9d237946","dirty":false,"date":"2026-10-17T00:54:10.912Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 128.0.6613.18","bandwidth":{"thread":[[524288,119.30464711111111],[2097152,119.83725663265307],[8388608,76.69584457142857],[33554432,81.09832063599298],[134217728,92.88424512158639]],"threads":[[524288,603.2257535159704],[2097152,239.1407175321551],[8388608,238.60929422222222],[33554432,235.46970120036937],[134217728,206.4888104142012]],"gpu":330.5855367516805},"results":{"move main":{"milliseconds":0.09999990463256836,"n":250000},"follow main":{"milliseconds":4.579999923706055,"n":1000000},"chain main":{"milliseconds":1.7450000047683716,"n":6000000},"sparse changed main":{"milliseconds":0.375,"n":1000000},"up depth 10 main":{"milliseconds":2.184999942779541,"n":1000000},"up depth 1000 main":{"milliseconds":2.609999895095825,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.7150000333786011,"n":1000000},"chain workers":{"milliseconds":0.2900000810623169,"n":6000000},"sparse changed workers":{"milliseconds":0.08500003814697266,"n":1000000},"up depth 10 workers":{"milliseconds":0.38499999046325684,"n":1000000},"up depth 1000 workers":{"milliseconds":2.615000009536743,"n":1000000},"up depth 10 gpu":{"milliseconds":0.2449999988079071,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.423000001907349,"n":1000000}}} +{"commit":"0cd47c75","dirty":false,"date":"2026-10-17T04:22:12.841Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,121.26285356508146],[2097152,115.3403233055508],[8388608,108.00058579762616],[33554432,103.58632069392105],[134217728,82.70367588384845]]},"results":{"run idle":{"milliseconds":0.0003329999999550637,"n":1},"run 50 idle":{"milliseconds":0.005417000000079497,"n":50},"sync point one spawn":{"milliseconds":0.0010000000000331966,"n":1},"move 1K":{"milliseconds":0.0006249999999852207,"n":1000},"move 10K":{"milliseconds":0.004249999999956344,"n":10000},"main thread 1K":{"milliseconds":0.007791999999994914,"n":1000},"main thread 10K":{"milliseconds":0.07395900000005895,"n":10000},"up depth 10 10K":{"milliseconds":0.018374999999991815,"n":10000},"create":{"milliseconds":90.7222499999998,"n":1000000},"create flat":{"milliseconds":70.98154199999999,"n":1000000},"create flat dynamic":{"milliseconds":225.8492500000002,"n":1000000},"move":{"milliseconds":0.09054199999991397,"n":250000},"churn":{"milliseconds":19.832417000000078,"n":1000000},"retag":{"milliseconds":35.76062500000012,"n":1000000},"sparse changed":{"milliseconds":0.3221250000000282,"n":1000000},"sparse changed clustered":{"milliseconds":0.02216699999999605,"n":1000000},"follow":{"milliseconds":3.8312080000000037,"n":1000000},"emit":{"milliseconds":8.653666000000044,"n":1000000},"reduce":{"milliseconds":0.5037919999999758,"n":1000000},"chain":{"milliseconds":1.5311250000000882,"n":6000000},"up depth 10":{"milliseconds":2.0541249999998854,"n":1000000},"up depth 1000":{"milliseconds":2.377832999999896,"n":1000000},"up one root changed":{"milliseconds":0.02279199999998127,"n":1000000},"up after a spawn":{"milliseconds":2.100666999999987,"n":1000000},"up filtered after churn":{"milliseconds":0.04558399999996254,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.039290999999934684,"n":1000000},"up filtered after a reparent":{"milliseconds":0.021457999999995536,"n":1000000},"up load":{"milliseconds":213.23512499999998,"n":1000000},"up small tree in a big world":{"milliseconds":0.011042000000003327,"n":1000},"main thread":{"milliseconds":0.4275830000000269,"n":50000},"select":{"milliseconds":22.15212499999984,"n":1000000}}} +{"commit":"0cd47c75","dirty":false,"date":"2026-10-17T04:22:47.178Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 128.0.6613.18","bandwidth":{"thread":[[524288,119.30464711111111],[2097152,120.37464291660802],[8388608,76.69584457142857],[33554432,67.108864],[134217728,91.61619449964478]],"threads":[[524288,583.5553663516081],[2097152,246.83720633108746],[8388608,241.83374102751404],[33554432,238.08022755443682],[134217728,204.5222521904762]],"gpu":329.1667112419136},"results":{"move main":{"milliseconds":0.09499990940093994,"n":250000},"follow main":{"milliseconds":4.490000009536743,"n":1000000},"chain main":{"milliseconds":1.725000023841858,"n":6000000},"sparse changed main":{"milliseconds":0.4249999523162842,"n":1000000},"up depth 10 main":{"milliseconds":2.190000057220459,"n":1000000},"up depth 1000 main":{"milliseconds":2.5549999475479126,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.7100000381469727,"n":1000000},"chain workers":{"milliseconds":0.30000007152557373,"n":6000000},"sparse changed workers":{"milliseconds":0.08500003814697266,"n":1000000},"up depth 10 workers":{"milliseconds":0.39499998092651367,"n":1000000},"up depth 1000 workers":{"milliseconds":2.5899999141693115,"n":1000000},"up depth 10 gpu":{"milliseconds":0.2694999992847443,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.303749996423721,"n":1000000}}} +{"commit":"c7dd5c16","dirty":false,"date":"2026-10-17T20:52:50.241Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,119.64145108942253],[2097152,114.26830946105144],[8388608,73.30132312711734],[33554432,93.1797722465094],[134217728,93.23103447078282]]},"results":{"run idle":{"milliseconds":0.0003329999999550637,"n":1},"run 50 idle":{"milliseconds":0.005417000000079497,"n":50},"sync point one spawn":{"milliseconds":0.001040999999986525,"n":1},"move 1K":{"milliseconds":0.0006659999999101274,"n":1000},"move 10K":{"milliseconds":0.0041669999999385254,"n":10000},"main thread 1K":{"milliseconds":0.007417000000032203,"n":1000},"main thread 10K":{"milliseconds":0.07054100000004837,"n":10000},"up depth 10 10K":{"milliseconds":0.017583000000001903,"n":10000},"create":{"milliseconds":93.89987500000007,"n":1000000},"create flat":{"milliseconds":69.76575000000048,"n":1000000},"create flat dynamic":{"milliseconds":226.7404579999993,"n":1000000},"move":{"milliseconds":0.08991600000001654,"n":250000},"churn":{"milliseconds":19.444166999999993,"n":1000000},"retag":{"milliseconds":35.783833999999956,"n":1000000},"sparse changed":{"milliseconds":0.3079169999999749,"n":1000000},"sparse changed clustered":{"milliseconds":0.02241699999999014,"n":1000000},"follow":{"milliseconds":3.823291999999924,"n":1000000},"emit":{"milliseconds":8.680458999999928,"n":1000000},"reduce":{"milliseconds":0.5039170000000013,"n":1000000},"chain":{"milliseconds":1.5561250000000655,"n":6000000},"up depth 10":{"milliseconds":2.0508749999999054,"n":1000000},"up depth 1000":{"milliseconds":2.38537499999984,"n":1000000},"up one root changed":{"milliseconds":0.023667000000045846,"n":1000000},"up after a spawn":{"milliseconds":2.0750830000000065,"n":1000000},"up filtered after churn":{"milliseconds":0.04737499999998818,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04166699999996126,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019459000000040305,"n":1000000},"up load":{"milliseconds":212.71300000000002,"n":1000000},"up small tree in a big world":{"milliseconds":0.01229200000000219,"n":1000},"main thread":{"milliseconds":0.42799999999999727,"n":50000},"select":{"milliseconds":8.267417000000023,"n":1000000}}} +{"commit":"c7dd5c16","dirty":false,"date":"2026-10-17T20:53:26.556Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 128.0.6613.18","bandwidth":{"thread":[[524288,119.83725663265307],[2097152,118.25350583943023],[8388608,73.94915910115436],[33554432,78.48989766423861],[134217728,90.38230754684896]],"threads":[[524288,613.5667565714285],[2097152,241.29029494255778],[8388608,244.0322406611573],[33554432,236.50701167886047],[134217728,205.30436330480498]],"gpu":329.77328804882615},"results":{"move main":{"milliseconds":0.09499990940093994,"n":250000},"follow main":{"milliseconds":4.509999990463257,"n":1000000},"chain main":{"milliseconds":1.725000023841858,"n":6000000},"sparse changed main":{"milliseconds":0.3700000047683716,"n":1000000},"up depth 10 main":{"milliseconds":2.1899999380111694,"n":1000000},"up depth 1000 main":{"milliseconds":2.59499990940094,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.7100000381469727,"n":1000000},"chain workers":{"milliseconds":0.28999996185302734,"n":6000000},"sparse changed workers":{"milliseconds":0.0700000524520874,"n":1000000},"up depth 10 workers":{"milliseconds":0.41499996185302734,"n":1000000},"up depth 1000 workers":{"milliseconds":2.5850000381469727,"n":1000000},"up depth 10 gpu":{"milliseconds":0.2612500011920929,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.400499999523163,"n":1000000}}} +{"commit":"4aa829f4","dirty":false,"date":"2026-10-17T21:23:47.226Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,123.18254306230611],[2097152,120.24437022490429],[8388608,64.08739946483077],[33554432,74.28514310730998],[134217728,93.52337113491768]]},"results":{"run idle":{"milliseconds":0.0003330000000687505,"n":1},"run 50 idle":{"milliseconds":0.005457999999975982,"n":50},"sync point one spawn":{"milliseconds":0.001040999999986525,"n":1},"move 1K":{"milliseconds":0.0006660000000238142,"n":1000},"move 10K":{"milliseconds":0.004415999999991982,"n":10000},"main thread 1K":{"milliseconds":0.007624999999961801,"n":1000},"main thread 10K":{"milliseconds":0.07220799999998917,"n":10000},"up depth 10 10K":{"milliseconds":0.01791600000001381,"n":10000},"create":{"milliseconds":90.96324999999979,"n":1000000},"create flat":{"milliseconds":69.63512500000024,"n":1000000},"create flat dynamic":{"milliseconds":224.60612499999934,"n":1000000},"move":{"milliseconds":0.09029199999986304,"n":250000},"churn":{"milliseconds":19.472415999999953,"n":1000000},"retag":{"milliseconds":35.74437499999999,"n":1000000},"sparse changed":{"milliseconds":0.3207500000000323,"n":1000000},"sparse changed clustered":{"milliseconds":0.022125000000016826,"n":1000000},"follow":{"milliseconds":3.826416999999992,"n":1000000},"emit":{"milliseconds":8.757624999999848,"n":1000000},"reduce":{"milliseconds":0.5036249999999427,"n":1000000},"chain":{"milliseconds":1.53774999999996,"n":6000000},"up depth 10":{"milliseconds":2.0308330000000296,"n":1000000},"up depth 1000":{"milliseconds":2.37175000000002,"n":1000000},"up one root changed":{"milliseconds":0.021875000000022737,"n":1000000},"up after a spawn":{"milliseconds":2.0728750000000105,"n":1000000},"up filtered after churn":{"milliseconds":0.04695799999990413,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04233299999998508,"n":1000000},"up filtered after a reparent":{"milliseconds":0.021957999999983713,"n":1000000},"up load":{"milliseconds":223.573792,"n":1000000},"up small tree in a big world":{"milliseconds":0.013666999999998097,"n":1000},"main thread":{"milliseconds":0.42695799999999906,"n":50000},"select":{"milliseconds":8.272499999999809,"n":1000000}}} +{"commit":"4aa829f4","dirty":false,"date":"2026-10-17T21:24:23.948Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 128.0.6613.18","bandwidth":{"thread":[[524288,116.20582798673196],[2097152,107.80540360316769],[8388608,61.85148538299823],[33554432,71.39240869171572],[134217728,83.36504770649282]],"threads":[[524288,635.3501702041252],[2097152,238.08022755443682],[8388608,238.60929422222222],[33554432,232.41165597346392],[134217728,198.47354053320868]],"gpu":339.14776663770823},"results":{"move main":{"milliseconds":0.09499990940093994,"n":250000},"follow main":{"milliseconds":4.564999938011169,"n":1000000},"chain main":{"milliseconds":1.7450000047683716,"n":6000000},"sparse changed main":{"milliseconds":0.6850000619888306,"n":1000000},"up depth 10 main":{"milliseconds":2.1899999380111694,"n":1000000},"up depth 1000 main":{"milliseconds":2.6149998903274536,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.7150000333786011,"n":1000000},"chain workers":{"milliseconds":0.2900000810623169,"n":6000000},"sparse changed workers":{"milliseconds":0.06999993324279785,"n":1000000},"up depth 10 workers":{"milliseconds":0.38499999046325684,"n":1000000},"up depth 1000 workers":{"milliseconds":2.6100000143051147,"n":1000000},"up depth 10 gpu":{"milliseconds":0.23774999976158143,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.296999996900558,"n":1000000}}} +{"commit":"d4256133","dirty":false,"date":"2026-10-18T02:07:28.866Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,120.89421802040698],[2097152,99.57114984294722],[8388608,61.42454451386976],[33554432,64.48255405585898],[134217728,86.37152870283526]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.00541699999996581,"n":50},"sync point one spawn":{"milliseconds":0.001082999999994172,"n":1},"move 1K":{"milliseconds":0.0006660000000238142,"n":1000},"move 10K":{"milliseconds":0.004207999999948697,"n":10000},"main thread 1K":{"milliseconds":0.007417000000032203,"n":1000},"main thread 10K":{"milliseconds":0.07195899999999256,"n":10000},"up depth 10 10K":{"milliseconds":0.017665999999962878,"n":10000},"create":{"milliseconds":90.04262500000004,"n":1000000},"create flat":{"milliseconds":70.11233300000004,"n":1000000},"create flat dynamic":{"milliseconds":434.1125000000002,"n":1000000},"move":{"milliseconds":0.09000000000003183,"n":250000},"churn":{"milliseconds":19.75237500000003,"n":1000000},"retag":{"milliseconds":33.62295900000004,"n":1000000},"sparse changed":{"milliseconds":0.3286249999999882,"n":1000000},"sparse changed clustered":{"milliseconds":0.023374999999987267,"n":1000000},"follow":{"milliseconds":3.8121249999999236,"n":1000000},"emit":{"milliseconds":8.714958000000138,"n":1000000},"reduce":{"milliseconds":0.50387499999988,"n":1000000},"chain":{"milliseconds":1.5578329999999596,"n":6000000},"up depth 10":{"milliseconds":2.05862500000012,"n":1000000},"up depth 1000":{"milliseconds":2.3662919999999303,"n":1000000},"up one root changed":{"milliseconds":0.02295800000001691,"n":1000000},"up after a spawn":{"milliseconds":2.088166000000001,"n":1000000},"up filtered after churn":{"milliseconds":0.04233299999998508,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04612499999996089,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020916999999997188,"n":1000000},"up load":{"milliseconds":217.176625,"n":1000000},"up small tree in a big world":{"milliseconds":0.010583000000025322,"n":1000},"main thread":{"milliseconds":0.4298750000000382,"n":50000},"select":{"milliseconds":8.253125000000068,"n":1000000}}} +{"commit":"4e63c933","dirty":false,"date":"2026-10-18T03:14:09.617Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,120.34312807766972],[2097152,115.06020402914709],[8388608,74.18930587991454],[33554432,73.0950567691678],[134217728,86.51767737810002]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005415999999968335,"n":50},"sync point one spawn":{"milliseconds":0.0010829999998804851,"n":1},"move 1K":{"milliseconds":0.0006659999999101274,"n":1000},"move 10K":{"milliseconds":0.004207999999948697,"n":10000},"main thread 1K":{"milliseconds":0.006624999999985448,"n":1000},"main thread 10K":{"milliseconds":0.06241699999998218,"n":10000},"up depth 10 10K":{"milliseconds":0.0175410000000511,"n":10000},"create":{"milliseconds":90.55845799999952,"n":1000000},"create flat":{"milliseconds":69.84370799999942,"n":1000000},"create flat dynamic":{"milliseconds":434.23562500000025,"n":1000000},"move":{"milliseconds":0.09199999999998454,"n":250000},"churn":{"milliseconds":20.642291,"n":1000000},"retag":{"milliseconds":35.63516600000003,"n":1000000},"sparse changed":{"milliseconds":0.35908399999999574,"n":1000000},"sparse changed clustered":{"milliseconds":0.023833999999993694,"n":1000000},"follow":{"milliseconds":3.9336250000000064,"n":1000000},"emit":{"milliseconds":9.242584000000079,"n":1000000},"reduce":{"milliseconds":0.5133749999999964,"n":1000000},"chain":{"milliseconds":1.5460829999999532,"n":6000000},"up depth 10":{"milliseconds":2.07162500000004,"n":1000000},"up depth 1000":{"milliseconds":2.426958999999897,"n":1000000},"up one root changed":{"milliseconds":0.029790999999931955,"n":1000000},"up after a spawn":{"milliseconds":2.0708329999999933,"n":1000000},"up filtered after churn":{"milliseconds":0.04666600000007293,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04141700000002402,"n":1000000},"up filtered after a reparent":{"milliseconds":0.02041700000000901,"n":1000000},"up load":{"milliseconds":226.936084,"n":1000000},"up small tree in a big world":{"milliseconds":0.011666999999988548,"n":1000},"main thread":{"milliseconds":0.3574170000000123,"n":50000},"select":{"milliseconds":8.090041000000042,"n":1000000},"spawn bulk":{"milliseconds":4.982874999999979,"n":100000},"spawn bulk kernel":{"milliseconds":12.035124999999994,"n":100000},"spawn singles":{"milliseconds":0.0018329999999195934,"n":4},"spawn sixteen archetypes":{"milliseconds":75.671875,"n":100000},"despawn bulk":{"milliseconds":6.520791999999972,"n":100000},"despawn bulk kernel":{"milliseconds":6.3120420000000195,"n":100000},"despawn one":{"milliseconds":0.0015409999999960178,"n":1},"add tag":{"milliseconds":10.703666999999996,"n":100000},"add tag kernel":{"milliseconds":10.37316599999997,"n":100000},"add tag from four archetypes":{"milliseconds":12.193458999999962,"n":100000},"add value":{"milliseconds":10.894416999999976,"n":100000},"add several":{"milliseconds":14.029499999999985,"n":100000},"remove tag":{"milliseconds":10.74883299999999,"n":100000},"remove tag kernel":{"milliseconds":8.30837500000007,"n":100000},"remove tag from four archetypes":{"milliseconds":12.218457999999941,"n":100000},"remove several":{"milliseconds":15.212874999999997,"n":100000},"add replace":{"milliseconds":7.514332999999965,"n":100000}}} +{"commit":"4e63c933","dirty":false,"date":"2026-10-18T03:14:46.186Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,119.83725663265307],[2097152,120.91687051375702],[8388608,82.59552492307692],[33554432,76.04403914324006],[134217728,84.67995253609853]],"threads":[[524288,679.583465726648],[2097152,242.3796405912897],[8388608,242.37965363594245],[33554432,239.1407175321551],[134217728,213.89279443818353]],"gpu":351.58540449940796},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.430000066757202,"n":1000000},"chain main":{"milliseconds":1.5299999713897705,"n":6000000},"sparse changed main":{"milliseconds":0.309999942779541,"n":1000000},"up depth 10 main":{"milliseconds":2.034999966621399,"n":1000000},"up depth 1000 main":{"milliseconds":2.340000033378601,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5199999809265137,"n":1000000},"chain workers":{"milliseconds":0.24499988555908203,"n":6000000},"sparse changed workers":{"milliseconds":0.06499993801116943,"n":1000000},"up depth 10 workers":{"milliseconds":0.34999990463256836,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3499999046325684,"n":1000000},"up depth 10 gpu":{"milliseconds":0.2369999945163727,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.365499997138977,"n":1000000}}} +{"commit":"bd0dabcc","dirty":false,"date":"2026-10-18T04:07:31.837Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,118.46224889673407],[2097152,112.7169666176778],[8388608,67.60889942011114],[33554432,74.01881660537833],[134217728,88.05250437580186]]},"results":{"run idle":{"milliseconds":0.0003330000000119071,"n":1},"run 50 idle":{"milliseconds":0.005415999999996757,"n":50},"sync point one spawn":{"milliseconds":0.001165999999898304,"n":1},"move 1K":{"milliseconds":0.0006670000000212895,"n":1000},"move 10K":{"milliseconds":0.004292000000077678,"n":10000},"main thread 1K":{"milliseconds":0.006749999999897227,"n":1000},"main thread 10K":{"milliseconds":0.06349999999997635,"n":10000},"up depth 10 10K":{"milliseconds":0.017999999999915417,"n":10000},"create":{"milliseconds":58.06945799999994,"n":1000000},"create flat":{"milliseconds":66.8261250000005,"n":1000000},"create flat dynamic":{"milliseconds":93.66095800000039,"n":1000000},"move":{"milliseconds":0.091666000000032,"n":250000},"churn":{"milliseconds":19.169874999999934,"n":1000000},"retag":{"milliseconds":33.91699999999997,"n":1000000},"sparse changed":{"milliseconds":0.321124999999995,"n":1000000},"sparse changed clustered":{"milliseconds":0.02287499999999909,"n":1000000},"follow":{"milliseconds":3.888582999999983,"n":1000000},"emit":{"milliseconds":9.11187499999994,"n":1000000},"reduce":{"milliseconds":0.5132079999999632,"n":1000000},"chain":{"milliseconds":1.5668750000002092,"n":6000000},"up depth 10":{"milliseconds":2.0507920000000013,"n":1000000},"up depth 1000":{"milliseconds":2.4199579999999514,"n":1000000},"up one root changed":{"milliseconds":0.023500000000012733,"n":1000000},"up after a spawn":{"milliseconds":2.093500000000006,"n":1000000},"up filtered after churn":{"milliseconds":0.04579200000000583,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.039709000000016204,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020582999999987805,"n":1000000},"up load":{"milliseconds":212.79575,"n":1000000},"up small tree in a big world":{"milliseconds":0.012958999999995058,"n":1000},"main thread":{"milliseconds":0.3601670000000041,"n":50000},"select":{"milliseconds":8.181958000000009,"n":1000000},"spawn bulk":{"milliseconds":4.0661249999999995,"n":100000},"spawn bulk kernel":{"milliseconds":13.065625000000068,"n":100000},"spawn singles":{"milliseconds":0.0019999999999527063,"n":4},"spawn sixteen archetypes":{"milliseconds":5.7636249999999905,"n":100000},"despawn bulk":{"milliseconds":4.415333000000032,"n":100000},"despawn bulk kernel":{"milliseconds":4.392125000000021,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":10.004415999999992,"n":100000},"add tag kernel":{"milliseconds":8.53120899999999,"n":100000},"add tag from four archetypes":{"milliseconds":10.906334000000015,"n":100000},"add value":{"milliseconds":9.561749999999961,"n":100000},"add several":{"milliseconds":14.216584000000012,"n":100000},"remove tag":{"milliseconds":7.238542000000052,"n":100000},"remove tag kernel":{"milliseconds":6.265375000000006,"n":100000},"remove tag from four archetypes":{"milliseconds":8.14570900000001,"n":100000},"remove several":{"milliseconds":7.921875,"n":100000},"add replace":{"milliseconds":7.758332999999993,"n":100000}}} +{"commit":"bd0dabcc","dirty":false,"date":"2026-10-18T04:08:10.101Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,118.25350583943023],[2097152,111.38401369122477],[8388608,64.68324463928009],[33554432,66.11710832099786],[134217728,88.30113795013852]],"threads":[[524288,858.9934592],[2097152,243.47886691656262],[8388608,239.1407175321551],[33554432,233.42214049149348],[134217728,200.69940098873278]],"gpu":342.8294438425743},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.4800000190734863,"n":1000000},"chain main":{"milliseconds":1.565000057220459,"n":6000000},"sparse changed main":{"milliseconds":0.2999999523162842,"n":1000000},"up depth 10 main":{"milliseconds":2.0149999856948853,"n":1000000},"up depth 1000 main":{"milliseconds":2.409999966621399,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5349999666213989,"n":1000000},"chain workers":{"milliseconds":0.24499988555908203,"n":6000000},"sparse changed workers":{"milliseconds":0.059999942779541016,"n":1000000},"up depth 10 workers":{"milliseconds":0.35500001907348633,"n":1000000},"up depth 1000 workers":{"milliseconds":2.444999933242798,"n":1000000},"up depth 10 gpu":{"milliseconds":0.23199999928474427,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.428249996900559,"n":1000000}}} +{"commit":"ebde4c9b","dirty":false,"date":"2026-10-18T04:54:07.586Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,122.8959395673569],[2097152,93.8777204855818],[8388608,70.38467222223123],[33554432,72.45550027639615],[134217728,83.02767759823224]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005415999999968335,"n":50},"sync point one spawn":{"milliseconds":0.0009159999999894808,"n":1},"move 1K":{"milliseconds":0.0006660000000238142,"n":1000},"move 10K":{"milliseconds":0.004291999999963991,"n":10000},"main thread 1K":{"milliseconds":0.006624999999985448,"n":1000},"main thread 10K":{"milliseconds":0.06287499999996271,"n":10000},"up depth 10 10K":{"milliseconds":0.017749999999978172,"n":10000},"create":{"milliseconds":56.31420900000012,"n":1000000},"create flat":{"milliseconds":64.34770800000024,"n":1000000},"create flat dynamic":{"milliseconds":94.83545799999956,"n":1000000},"move":{"milliseconds":0.09183299999995143,"n":250000},"churn":{"milliseconds":19.425416999999925,"n":1000000},"retag":{"milliseconds":33.566249999999854,"n":1000000},"sparse changed":{"milliseconds":0.33333300000003874,"n":1000000},"sparse changed clustered":{"milliseconds":0.022582999999997355,"n":1000000},"follow":{"milliseconds":3.895750000000021,"n":1000000},"emit":{"milliseconds":8.708666999999991,"n":1000000},"reduce":{"milliseconds":0.5132080000000769,"n":1000000},"chain":{"milliseconds":1.5565420000000358,"n":6000000},"up depth 10":{"milliseconds":2.056542000000036,"n":1000000},"up depth 1000":{"milliseconds":2.400291000000152,"n":1000000},"up one root changed":{"milliseconds":0.024499999999989086,"n":1000000},"up after a spawn":{"milliseconds":2.069999999999993,"n":1000000},"up filtered after churn":{"milliseconds":0.04737499999998818,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.0378329999999778,"n":1000000},"up filtered after a reparent":{"milliseconds":0.021208999999998923,"n":1000000},"up load":{"milliseconds":227.942542,"n":1000000},"up small tree in a big world":{"milliseconds":0.011459000000002106,"n":1000},"main thread":{"milliseconds":0.3624590000000012,"n":50000},"select":{"milliseconds":8.194124999999985,"n":1000000},"spawn bulk":{"milliseconds":4.0440830000000005,"n":100000},"spawn bulk kernel":{"milliseconds":12.061916999999994,"n":100000},"spawn singles":{"milliseconds":0.0011660000000119908,"n":4},"spawn sixteen archetypes":{"milliseconds":5.645791999999972,"n":100000},"despawn bulk":{"milliseconds":4.334959000000026,"n":100000},"despawn bulk kernel":{"milliseconds":4.512749999999983,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":9.76520800000003,"n":100000},"add tag kernel":{"milliseconds":8.203332999999986,"n":100000},"add tag from four archetypes":{"milliseconds":10.210542000000032,"n":100000},"add value":{"milliseconds":8.960916999999995,"n":100000},"add several":{"milliseconds":13.98075,"n":100000},"remove tag":{"milliseconds":6.861249999999984,"n":100000},"remove tag kernel":{"milliseconds":5.829457999999988,"n":100000},"remove tag from four archetypes":{"milliseconds":7.652375000000006,"n":100000},"remove several":{"milliseconds":7.7776250000000005,"n":100000},"add replace":{"milliseconds":7.470542000000023,"n":100000}}} +{"commit":"ebde4c9b","dirty":false,"date":"2026-10-18T04:55:21.461Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,122.01611371900832],[2097152,107.80540360316769],[8388608,60.18732145830401],[33554432,84.947933215831],[134217728,86.87231827065077]],"threads":[[524288,750.8683733385512],[2097152,245.14654209878879],[8388608,246.27106647588602],[33554432,237.02910171776094],[134217728,215.61080720633538]],"gpu":348.8440004272709},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.424999952316284,"n":1000000},"chain main":{"milliseconds":1.5549999475479126,"n":6000000},"sparse changed main":{"milliseconds":0.3049999475479126,"n":1000000},"up depth 10 main":{"milliseconds":2.049999952316284,"n":1000000},"up depth 1000 main":{"milliseconds":2.3799999952316284,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5299999713897705,"n":1000000},"chain workers":{"milliseconds":0.24499988555908203,"n":6000000},"sparse changed workers":{"milliseconds":0.060000061988830566,"n":1000000},"up depth 10 workers":{"milliseconds":0.3549998998641968,"n":1000000},"up depth 1000 workers":{"milliseconds":2.399999976158142,"n":1000000},"up depth 10 gpu":{"milliseconds":0.23249999880790712,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.424000000953674,"n":1000000}}} +{"commit":"2b07c576","dirty":false,"date":"2026-10-18T06:29:39.580Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,120.13222465876049],[2097152,112.68942714818328],[8388608,72.26528084975325],[33554432,66.3636663901055],[134217728,82.8760284038275]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005540999999993801,"n":50},"sync point one spawn":{"milliseconds":0.0008749999999508873,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.004290999999966516,"n":10000},"main thread 1K":{"milliseconds":0.006624999999985448,"n":1000},"main thread 10K":{"milliseconds":0.06258299999990413,"n":10000},"up depth 10 10K":{"milliseconds":0.01766700000007404,"n":10000},"create":{"milliseconds":57.98029200000019,"n":1000000},"create flat":{"milliseconds":67.32400000000007,"n":1000000},"create flat dynamic":{"milliseconds":95.10512500000004,"n":1000000},"move":{"milliseconds":0.09162500000002183,"n":250000},"churn":{"milliseconds":17.938250000000153,"n":1000000},"retag":{"milliseconds":33.544749999999965,"n":1000000},"sparse changed":{"milliseconds":0.37195800000000645,"n":1000000},"sparse changed clustered":{"milliseconds":0.023625000000009777,"n":1000000},"follow":{"milliseconds":3.8261660000000006,"n":1000000},"emit":{"milliseconds":8.651708000000099,"n":1000000},"reduce":{"milliseconds":0.5133339999999862,"n":1000000},"chain":{"milliseconds":1.571666999999934,"n":6000000},"up depth 10":{"milliseconds":2.02862499999992,"n":1000000},"up depth 1000":{"milliseconds":2.3544169999998985,"n":1000000},"up one root changed":{"milliseconds":0.023000000000024556,"n":1000000},"up after a spawn":{"milliseconds":2.0591249999999945,"n":1000000},"up filtered after churn":{"milliseconds":0.044249999999919964,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.040541999999959444,"n":1000000},"up filtered after a reparent":{"milliseconds":0.02070800000001327,"n":1000000},"up load":{"milliseconds":217.007125,"n":1000000},"up small tree in a big world":{"milliseconds":0.011167000000000371,"n":1000},"main thread":{"milliseconds":0.37341700000000344,"n":50000},"select":{"milliseconds":7.994166999999976,"n":1000000},"spawn bulk":{"milliseconds":2.5418339999999944,"n":100000},"spawn bulk kernel":{"milliseconds":1.0260829999999999,"n":100000},"spawn singles":{"milliseconds":0.0011249999998881322,"n":4},"spawn sixteen archetypes":{"milliseconds":4.112166000000002,"n":100000},"despawn bulk":{"milliseconds":4.267875000000004,"n":100000},"despawn bulk kernel":{"milliseconds":4.269124999999974,"n":100000},"despawn one":{"milliseconds":0.0015409999999960178,"n":1},"add tag":{"milliseconds":9.814209000000005,"n":100000},"add tag kernel":{"milliseconds":8.30295799999999,"n":100000},"add tag from four archetypes":{"milliseconds":10.235709000000043,"n":100000},"add value":{"milliseconds":9.317250000000001,"n":100000},"add several":{"milliseconds":14.29358400000001,"n":100000},"remove tag":{"milliseconds":7.154207999999926,"n":100000},"remove tag kernel":{"milliseconds":6.097792000000027,"n":100000},"remove tag from four archetypes":{"milliseconds":8.013958000000002,"n":100000},"remove several":{"milliseconds":7.556165999999962,"n":100000},"add replace":{"milliseconds":7.190500000000043,"n":100000}}} +{"commit":"2b07c576","dirty":false,"date":"2026-10-18T06:30:16.211Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,105.26880824298351],[2097152,97.6128930909091],[8388608,62.86544665083448],[33554432,58.73860960981387],[134217728,97.25922352446965]],"threads":[[524288,825.9551280473551],[2097152,244.03222743801663],[8388608,244.58810896165974],[33554432,231.90968741935652],[134217728,216.04463135189405]],"gpu":350.43792818820776},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.4299999475479126,"n":1000000},"chain main":{"milliseconds":1.5500000715255737,"n":6000000},"sparse changed main":{"milliseconds":0.30500006675720215,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.100000023841858,"n":100000},"up depth 10 main":{"milliseconds":2.0549999475479126,"n":1000000},"up depth 1000 main":{"milliseconds":2.384999990463257,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5149999856948853,"n":1000000},"chain workers":{"milliseconds":0.24000000953674316,"n":6000000},"sparse changed workers":{"milliseconds":0.059999942779541016,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7649999856948853,"n":100000},"up depth 10 workers":{"milliseconds":0.3400000333786011,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3799999952316284,"n":1000000},"up depth 10 gpu":{"milliseconds":0.23199999928474427,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.308750003576279,"n":1000000}}} +{"commit":"163299c6","dirty":false,"date":"2026-10-18T09:48:19.175Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,119.11935034390935],[2097152,115.85055008795568],[8388608,75.14641144896382],[33554432,76.54459245673429],[134217728,80.92108101590169]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005457999999975982,"n":50},"loop tick idle":{"milliseconds":19.209957999999986,"n":10000},"sync point one spawn":{"milliseconds":0.0008330000000000837,"n":1},"move 1K":{"milliseconds":0.0006659999999953925,"n":1000},"move 10K":{"milliseconds":0.004250000000013188,"n":10000},"main thread 1K":{"milliseconds":0.006582999999977801,"n":1000},"main thread 10K":{"milliseconds":0.061583000000041466,"n":10000},"up depth 10 10K":{"milliseconds":0.017624999999952706,"n":10000},"create":{"milliseconds":60.18616600000041,"n":1000000},"create flat":{"milliseconds":67.32270800000015,"n":1000000},"create flat dynamic":{"milliseconds":97.9552079999994,"n":1000000},"move":{"milliseconds":0.0917500000000473,"n":250000},"churn":{"milliseconds":17.91483299999993,"n":1000000},"retag":{"milliseconds":33.24758299999996,"n":1000000},"sparse changed":{"milliseconds":0.3224590000000376,"n":1000000},"sparse changed clustered":{"milliseconds":0.02270800000002282,"n":1000000},"follow":{"milliseconds":3.862292000000025,"n":1000000},"emit":{"milliseconds":8.441625000000045,"n":1000000},"reduce":{"milliseconds":0.5042499999999563,"n":1000000},"chain":{"milliseconds":1.5476249999999254,"n":6000000},"up depth 10":{"milliseconds":2.0579579999998714,"n":1000000},"up depth 1000":{"milliseconds":2.3817499999997835,"n":1000000},"up one root changed":{"milliseconds":0.020916999999940344,"n":1000000},"up after a spawn":{"milliseconds":2.091666000000032,"n":1000000},"up filtered after churn":{"milliseconds":0.04695800000001782,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04370800000003783,"n":1000000},"up filtered after a reparent":{"milliseconds":0.023167000000000826,"n":1000000},"up load":{"milliseconds":236.98383300000012,"n":1000000},"up small tree in a big world":{"milliseconds":0.01125000000001819,"n":1000},"main thread":{"milliseconds":0.35949999999999704,"n":50000},"select":{"milliseconds":8.152415999999903,"n":1000000},"spawn bulk":{"milliseconds":2.563791000000009,"n":100000},"spawn bulk kernel":{"milliseconds":1.0416659999999922,"n":100000},"spawn singles":{"milliseconds":0.001082999999994172,"n":4},"spawn sixteen archetypes":{"milliseconds":3.981999999999971,"n":100000},"despawn bulk":{"milliseconds":4.280333999999982,"n":100000},"despawn bulk kernel":{"milliseconds":4.4093749999999545,"n":100000},"despawn one":{"milliseconds":0.0015000000000000568,"n":1},"add tag":{"milliseconds":9.641166999999996,"n":100000},"add tag kernel":{"milliseconds":7.974166000000082,"n":100000},"add tag from four archetypes":{"milliseconds":10.025833000000034,"n":100000},"add value":{"milliseconds":9.099708000000078,"n":100000},"add several":{"milliseconds":13.777167000000077,"n":100000},"remove tag":{"milliseconds":6.8357500000000755,"n":100000},"remove tag kernel":{"milliseconds":5.789332999999999,"n":100000},"remove tag from four archetypes":{"milliseconds":7.6596250000000055,"n":100000},"remove several":{"milliseconds":7.651749999999993,"n":100000},"add replace":{"milliseconds":7.4864579999999705,"n":100000}}} +{"commit":"163299c6","dirty":false,"date":"2026-10-18T09:49:22.540Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.91687051375702],[2097152,108.67832100837583],[8388608,63.45991838997591],[33554432,77.35892039963791],[134217728,81.8400787626413]],"threads":[[524288,832.3580277146814],[2097152,240.74928583321605],[8388608,244.0322406611573],[33554432,237.02910171776094],[134217728,203.74607739775104]],"gpu":357.1995429287798},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.4550000429153442,"n":1000000},"chain main":{"milliseconds":1.5449999570846558,"n":6000000},"sparse changed main":{"milliseconds":0.2999999523162842,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0600000619888306,"n":100000},"up depth 10 main":{"milliseconds":2.0700000524520874,"n":1000000},"up depth 1000 main":{"milliseconds":2.3949999809265137,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5199999809265137,"n":1000000},"chain workers":{"milliseconds":0.2399998903274536,"n":6000000},"sparse changed workers":{"milliseconds":0.060000061988830566,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6949999332427979,"n":100000},"up depth 10 workers":{"milliseconds":0.3450000286102295,"n":1000000},"up depth 1000 workers":{"milliseconds":2.399999976158142,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.08000004291534424,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6150000095367432,"n":100},"loop tick idle":{"milliseconds":44.02499997615814,"n":10000},"up depth 10 gpu":{"milliseconds":0.23374999761581422,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.463499999046325,"n":1000000}}} +{"commit":"76276e25","dirty":false,"date":"2026-10-18T10:26:19.460Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,104.98407300044268],[2097152,101.78936631216968],[8388608,63.40370971361107],[33554432,72.46199379133495],[134217728,80.88245932155736]],"threads":[[524288,597.5191007234384],[2097152,241.42593007307528],[8388608,235.29766263000124],[33554432,234.07520547544914],[134217728,208.02233697414877]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005540999999993801,"n":50},"loop tick idle":{"milliseconds":18.933499999999995,"n":10000},"sync point one spawn":{"milliseconds":0.0008339999999975589,"n":1},"move 1K":{"milliseconds":0.0006660000000238142,"n":1000},"move 10K":{"milliseconds":0.004290999999966516,"n":10000},"main thread 1K":{"milliseconds":0.006624999999985448,"n":1000},"main thread 10K":{"milliseconds":0.06274999999999409,"n":10000},"up depth 10 10K":{"milliseconds":0.017582999999831372,"n":10000},"create":{"milliseconds":57.6524159999999,"n":1000000},"create flat":{"milliseconds":66.94716700000026,"n":1000000},"create flat dynamic":{"milliseconds":97.37970800000039,"n":1000000},"move":{"milliseconds":0.09183299999995143,"n":250000},"churn":{"milliseconds":14.702666999999906,"n":1000000},"retag":{"milliseconds":34.17266699999993,"n":1000000},"sparse changed":{"milliseconds":0.3221249999999998,"n":1000000},"sparse changed clustered":{"milliseconds":0.023041000000006306,"n":1000000},"follow":{"milliseconds":3.8949579999998605,"n":1000000},"emit":{"milliseconds":8.666166999999973,"n":1000000},"reduce":{"milliseconds":0.5132499999999709,"n":1000000},"chain":{"milliseconds":1.57062499999995,"n":6000000},"up depth 10":{"milliseconds":2.0507920000000013,"n":1000000},"up depth 1000":{"milliseconds":2.3850419999999986,"n":1000000},"up one root changed":{"milliseconds":0.024999999999977263,"n":1000000},"up after a spawn":{"milliseconds":2.1092499999999745,"n":1000000},"up filtered after churn":{"milliseconds":0.03912500000001273,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.042374999999992724,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020458000000019183,"n":1000000},"up load":{"milliseconds":224.61925,"n":1000000},"up small tree in a big world":{"milliseconds":0.011166000000002896,"n":1000},"main thread":{"milliseconds":0.33662499999999795,"n":50000},"select":{"milliseconds":8.323208000000022,"n":1000000},"spawn bulk":{"milliseconds":2.5658750000000055,"n":100000},"spawn bulk kernel":{"milliseconds":1.040916999999979,"n":100000},"spawn singles":{"milliseconds":0.001082999999994172,"n":4},"spawn sixteen archetypes":{"milliseconds":3.964125000000024,"n":100000},"despawn bulk":{"milliseconds":4.597790999999972,"n":100000},"despawn bulk kernel":{"milliseconds":4.437333000000024,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":10.126082999999994,"n":100000},"add tag kernel":{"milliseconds":8.643167000000062,"n":100000},"add tag from four archetypes":{"milliseconds":10.679500000000075,"n":100000},"add value":{"milliseconds":9.341833999999949,"n":100000},"add several":{"milliseconds":14.383874999999989,"n":100000},"remove tag":{"milliseconds":7.39995799999997,"n":100000},"remove tag kernel":{"milliseconds":6.212625000000003,"n":100000},"remove tag from four archetypes":{"milliseconds":7.799167000000011,"n":100000},"remove several":{"milliseconds":8.023541999999907,"n":100000},"add replace":{"milliseconds":7.703082999999992,"n":100000},"move workers":{"milliseconds":0.019833000000062384,"n":250000},"follow workers":{"milliseconds":0.5859169999999949,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7386659999999665,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8944999999999936,"n":100},"wake":{"milliseconds":7.480083000000008,"n":10000}}} +{"commit":"76276e25","dirty":false,"date":"2026-10-18T10:27:12.482Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,122.01611371900832],[2097152,106.52200715545479],[8388608,71.01467143696985],[33554432,74.9819675540715],[134217728,89.18121490049779]],"threads":[[524288,740.5117001664813],[2097152,239.1407175321551],[8388608,245.707517647367],[33554432,237.5535018873835],[134217728,213.89279443818353]],"gpu":347.26449590898517},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.4899998903274536,"n":1000000},"chain main":{"milliseconds":1.5399999618530273,"n":6000000},"sparse changed main":{"milliseconds":0.34499990940093994,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0700000524520874,"n":100000},"up depth 10 main":{"milliseconds":2.0399999618530273,"n":1000000},"up depth 1000 main":{"milliseconds":2.369999885559082,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5249999761581421,"n":1000000},"chain workers":{"milliseconds":0.23500001430511475,"n":6000000},"sparse changed workers":{"milliseconds":0.05500006675720215,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7549999952316284,"n":100000},"up depth 10 workers":{"milliseconds":0.3400000333786011,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3899999856948853,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07500004768371582,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.625,"n":100},"wake":{"milliseconds":7.610000014305115,"n":10000},"loop tick idle":{"milliseconds":45.62000000476837,"n":10000},"up depth 10 gpu":{"milliseconds":0.23300000429153442,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.32674999833107,"n":1000000}}} +{"commit":"d345e631","dirty":true,"date":"2026-10-18T11:04:01.815Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,115.92149126860227],[2097152,93.96532983285185],[8388608,71.86388144347009],[33554432,94.72799505954976],[134217728,78.69893483158222]],"threads":[[524288,721.4794718629294],[2097152,239.4919017106581],[8388608,237.62363139917565],[33554432,230.80054253318383],[134217728,189.98113249610628]]},"results":{"run idle":{"milliseconds":0.00037500000001955414,"n":1},"run 50 idle":{"milliseconds":0.005708999999910702,"n":50},"loop tick idle":{"milliseconds":19.31970799999999,"n":10000},"sync point one spawn":{"milliseconds":0.0008330000000000837,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.004415999999991982,"n":10000},"main thread 1K":{"milliseconds":0.006832999999915046,"n":1000},"main thread 10K":{"milliseconds":0.06399999999996453,"n":10000},"up depth 10 10K":{"milliseconds":0.017957999999964613,"n":10000},"create":{"milliseconds":60.77762500000017,"n":1000000},"create flat":{"milliseconds":67.62254200000007,"n":1000000},"create flat dynamic":{"milliseconds":95.59412499999962,"n":1000000},"move":{"milliseconds":0.0920410000001084,"n":250000},"churn":{"milliseconds":15.080249999999978,"n":1000000},"retag":{"milliseconds":34.548791999999935,"n":1000000},"sparse changed":{"milliseconds":0.31820899999999597,"n":1000000},"sparse changed clustered":{"milliseconds":0.022875000000027512,"n":1000000},"follow":{"milliseconds":3.891291000000024,"n":1000000},"emit":{"milliseconds":8.670999999999935,"n":1000000},"reduce":{"milliseconds":0.5133749999999964,"n":1000000},"chain":{"milliseconds":1.5679580000000328,"n":6000000},"up depth 10":{"milliseconds":2.045333000000028,"n":1000000},"up depth 1000":{"milliseconds":2.4160000000000537,"n":1000000},"up one root changed":{"milliseconds":0.02333299999997962,"n":1000000},"up after a spawn":{"milliseconds":2.086749999999995,"n":1000000},"up filtered after churn":{"milliseconds":0.04833300000007057,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04475000000002183,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020000000000038654,"n":1000000},"up load":{"milliseconds":228.963667,"n":1000000},"up small tree in a big world":{"milliseconds":0.01129099999999994,"n":1000},"main thread":{"milliseconds":0.3346250000000026,"n":50000},"select":{"milliseconds":8.415041999999858,"n":1000000},"spawn bulk":{"milliseconds":2.564958000000047,"n":100000},"spawn bulk kernel":{"milliseconds":1.0392080000000306,"n":100000},"spawn singles":{"milliseconds":0.001082999999994172,"n":4},"spawn sixteen archetypes":{"milliseconds":4.035833000000025,"n":100000},"despawn bulk":{"milliseconds":4.417541999999969,"n":100000},"despawn bulk kernel":{"milliseconds":4.59333300000003,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":10.50249999999994,"n":100000},"add tag kernel":{"milliseconds":8.618875000000003,"n":100000},"add tag from four archetypes":{"milliseconds":10.661417000000029,"n":100000},"add value":{"milliseconds":9.783665999999982,"n":100000},"add several":{"milliseconds":13.869459000000006,"n":100000},"remove tag":{"milliseconds":7.17470800000001,"n":100000},"remove tag kernel":{"milliseconds":6.123667000000069,"n":100000},"remove tag from four archetypes":{"milliseconds":7.304708999999889,"n":100000},"remove several":{"milliseconds":7.8034999999999854,"n":100000},"add replace":{"milliseconds":7.642041000000006,"n":100000},"emit main":{"milliseconds":8.660750000000007,"n":1000000},"move workers":{"milliseconds":0.019666000000142958,"n":250000},"follow workers":{"milliseconds":0.5881249999999909,"n":1000000},"emit workers":{"milliseconds":6.786958999999911,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6834589999999707,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.7992499999999723,"n":100},"wake":{"milliseconds":7.463374999999999,"n":10000}}} +{"commit":"d345e631","dirty":true,"date":"2026-10-18T11:04:45.741Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,119.83725663265307],[2097152,107.3741824],[8388608,73.74600710059181],[33554432,79.89150725623591],[134217728,82.85044913885079]],"threads":[[524288,778.0738554085339],[2097152,244.58810896165974],[8388608,244.03222743801663],[33554432,236.50699925867033],[134217728,206.88666900999033]],"gpu":347.26449590898517},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.4799998998641968,"n":1000000},"chain main":{"milliseconds":1.5450000762939453,"n":6000000},"sparse changed main":{"milliseconds":0.2999999523162842,"n":1000000},"emit main":{"milliseconds":9.354999899864197,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0500000715255737,"n":100000},"up depth 10 main":{"milliseconds":2.034999966621399,"n":1000000},"up depth 1000 main":{"milliseconds":2.3700000047683716,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5249999761581421,"n":1000000},"chain workers":{"milliseconds":0.2349998950958252,"n":6000000},"sparse changed workers":{"milliseconds":0.0549999475479126,"n":1000000},"emit workers":{"milliseconds":8.684999942779541,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6749999523162842,"n":100000},"up depth 10 workers":{"milliseconds":0.3400000333786011,"n":1000000},"up depth 1000 workers":{"milliseconds":2.384999990463257,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07500004768371582,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6150000095367432,"n":100},"wake":{"milliseconds":7.830000042915344,"n":10000},"loop tick idle":{"milliseconds":45.06000006198883,"n":10000},"up depth 10 gpu":{"milliseconds":0.23299999833106994,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.385499995946884,"n":1000000}}} +{"commit":"c4ecbd8d","dirty":false,"date":"2026-10-18T11:18:43.557Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,118.4099938244378],[2097152,109.46871296641393],[8388608,76.05304424149932],[33554432,74.03245304083126],[134217728,91.58493892869203]],"threads":[[524288,709.0137874930086],[2097152,234.68063607187398],[8388608,244.66714396586815],[33554432,233.2150812191987],[134217728,213.32268662077655]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005625000000009095,"n":50},"loop tick idle":{"milliseconds":18.681624999999997,"n":10000},"sync point one spawn":{"milliseconds":0.0008749999999508873,"n":1},"move 1K":{"milliseconds":0.0006660000000238142,"n":1000},"move 10K":{"milliseconds":0.004249999999984766,"n":10000},"main thread 1K":{"milliseconds":0.00666599999999562,"n":1000},"main thread 10K":{"milliseconds":0.063083000000006,"n":10000},"up depth 10 10K":{"milliseconds":0.017707999999970525,"n":10000},"create":{"milliseconds":59.52891699999964,"n":1000000},"create flat":{"milliseconds":67.73691600000029,"n":1000000},"create flat dynamic":{"milliseconds":96.15295800000058,"n":1000000},"move":{"milliseconds":0.09179099999994378,"n":250000},"churn":{"milliseconds":15.34216600000002,"n":1000000},"retag":{"milliseconds":35.217625,"n":1000000},"sparse changed":{"milliseconds":0.5042499999999848,"n":1000000},"sparse changed clustered":{"milliseconds":0.024334000000010292,"n":1000000},"follow":{"milliseconds":4.044750000000022,"n":1000000},"emit":{"milliseconds":8.801207999999974,"n":1000000},"reduce":{"milliseconds":0.5133749999999964,"n":1000000},"chain":{"milliseconds":1.5516659999999547,"n":6000000},"up depth 10":{"milliseconds":2.0934159999999338,"n":1000000},"up depth 1000":{"milliseconds":2.4260410000001684,"n":1000000},"up one root changed":{"milliseconds":0.022249999999985448,"n":1000000},"up after a spawn":{"milliseconds":2.1333329999999933,"n":1000000},"up filtered after churn":{"milliseconds":0.04512499999998454,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04304099999990285,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020332999999993717,"n":1000000},"up load":{"milliseconds":232.77466600000002,"n":1000000},"up small tree in a big world":{"milliseconds":0.010791000000011763,"n":1000},"main thread":{"milliseconds":0.33466699999999605,"n":50000},"select":{"milliseconds":8.351167000000032,"n":1000000},"spawn bulk":{"milliseconds":2.5564580000000205,"n":100000},"spawn bulk kernel":{"milliseconds":1.0454999999999899,"n":100000},"spawn singles":{"milliseconds":0.001082999999994172,"n":4},"spawn sixteen archetypes":{"milliseconds":4.020667000000003,"n":100000},"despawn bulk":{"milliseconds":4.375625000000014,"n":100000},"despawn bulk kernel":{"milliseconds":4.597749999999991,"n":100000},"despawn one":{"milliseconds":0.0015410000000031232,"n":1},"add tag":{"milliseconds":10.318624999999997,"n":100000},"add tag kernel":{"milliseconds":8.499541999999991,"n":100000},"add tag from four archetypes":{"milliseconds":10.74308300000007,"n":100000},"add value":{"milliseconds":9.575207999999975,"n":100000},"add several":{"milliseconds":13.881541000000084,"n":100000},"remove tag":{"milliseconds":7.069958999999983,"n":100000},"remove tag kernel":{"milliseconds":6.0587920000000395,"n":100000},"remove tag from four archetypes":{"milliseconds":7.591625000000022,"n":100000},"remove several":{"milliseconds":7.889750000000049,"n":100000},"add replace":{"milliseconds":7.7864999999999895,"n":100000},"emit main":{"milliseconds":8.766999999999939,"n":1000000},"move workers":{"milliseconds":0.019375000000081855,"n":250000},"follow workers":{"milliseconds":0.599999999999909,"n":1000000},"emit workers":{"milliseconds":6.371125000000006,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6886250000000018,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8111250000000041,"n":100},"wake":{"milliseconds":7.348417000000012,"n":10000}}} +{"commit":"c4ecbd8d","dirty":false,"date":"2026-10-18T11:19:28.318Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,121.46400514321988],[2097152,96.90810752648933],[8388608,65.47206015466992],[33554432,72.55012230825422],[134217728,86.59208524453702]],"threads":[[524288,692.7366819562961],[2097152,240.74928583321605],[8388608,241.83374102751404],[33554432,237.02910171776094],[134217728,206.88666900999033]],"gpu":349.9810397678692},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.4850000143051147,"n":1000000},"chain main":{"milliseconds":1.5299999713897705,"n":6000000},"sparse changed main":{"milliseconds":0.2900000810623169,"n":1000000},"emit main":{"milliseconds":9.289999961853027,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.040000081062317,"n":100000},"up depth 10 main":{"milliseconds":2.049999952316284,"n":1000000},"up depth 1000 main":{"milliseconds":2.375,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5300000905990601,"n":1000000},"chain workers":{"milliseconds":0.2399998903274536,"n":6000000},"sparse changed workers":{"milliseconds":0.0549999475479126,"n":1000000},"emit workers":{"milliseconds":7.890000104904175,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.684999942779541,"n":100000},"up depth 10 workers":{"milliseconds":0.3399999141693115,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3899999856948853,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.0849999189376831,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6449999809265137,"n":100},"wake":{"milliseconds":7.639999985694885,"n":10000},"loop tick idle":{"milliseconds":44.44500005245209,"n":10000},"up depth 10 gpu":{"milliseconds":0.23249999880790712,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.436000001430512,"n":1000000}}} +{"commit":"c4ecbd8d","dirty":false,"date":"2026-10-18T11:19:49.038Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,112.08947266447112],[2097152,106.69848225159694],[8388608,65.97761186692938],[33554432,69.34972705547993],[134217728,96.74221317235846]],"threads":[[524288,698.7476940337211],[2097152,243.31335236800615],[8388608,238.82158007117542],[33554432,235.8014750578884],[134217728,196.8452860351074]]},"results":{"sparse changed":{"milliseconds":0.3187080000000151,"n":1000000}}} +{"commit":"f48c8041","dirty":true,"date":"2026-10-18T12:02:15.598Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,115.7922812466304],[2097152,106.00669602132471],[8388608,59.91528508453753],[33554432,74.23374457009352],[134217728,80.85200735884975]],"threads":[[524288,525.6781723542397],[2097152,247.8390403876286],[8388608,242.97847924071093],[33554432,230.89563545327925],[134217728,215.43776565007866]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005707999999913227,"n":50},"loop tick idle":{"milliseconds":19.548541,"n":10000},"sync point one spawn":{"milliseconds":0.0009159999999610591,"n":1},"move 1K":{"milliseconds":0.0006659999999669708,"n":1000},"move 10K":{"milliseconds":0.004332999999974163,"n":10000},"main thread 1K":{"milliseconds":0.006750000000010914,"n":1000},"main thread 10K":{"milliseconds":0.06283400000000938,"n":10000},"up depth 10 10K":{"milliseconds":0.017875000000003638,"n":10000},"create":{"milliseconds":59.66358300000047,"n":1000000},"create flat":{"milliseconds":66.00458400000025,"n":1000000},"create flat dynamic":{"milliseconds":93.69499999999971,"n":1000000},"move":{"milliseconds":0.09004199999992579,"n":250000},"churn":{"milliseconds":15.030332999999928,"n":1000000},"retag":{"milliseconds":33.824374999999975,"n":1000000},"sparse changed":{"milliseconds":0.31683299999997416,"n":1000000},"sparse changed clustered":{"milliseconds":0.02241700000001856,"n":1000000},"follow":{"milliseconds":3.8902909999999338,"n":1000000},"emit":{"milliseconds":8.70812500000011,"n":1000000},"reduce":{"milliseconds":0.5040420000000267,"n":1000000},"chain":{"milliseconds":1.5574999999998909,"n":6000000},"up depth 10":{"milliseconds":2.0640410000000884,"n":1000000},"up depth 1000":{"milliseconds":2.417874999999981,"n":1000000},"up one root changed":{"milliseconds":0.02266599999995833,"n":1000000},"up after a spawn":{"milliseconds":2.1011669999999754,"n":1000000},"up filtered after churn":{"milliseconds":0.043833000000063294,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.039041999999994914,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019875000000013188,"n":1000000},"up load":{"milliseconds":218.79741599999997,"n":1000000},"up small tree in a big world":{"milliseconds":0.010791000000011763,"n":1000},"main thread":{"milliseconds":0.332374999999999,"n":50000},"select":{"milliseconds":8.329750000000104,"n":1000000},"spawn bulk":{"milliseconds":2.563083000000006,"n":100000},"spawn bulk kernel":{"milliseconds":1.0581669999999974,"n":100000},"spawn singles":{"milliseconds":0.001165999999898304,"n":4},"spawn sixteen archetypes":{"milliseconds":4.028209000000004,"n":100000},"despawn bulk":{"milliseconds":4.396999999999991,"n":100000},"despawn bulk kernel":{"milliseconds":4.588875000000002,"n":100000},"despawn one":{"milliseconds":0.0015000000000000568,"n":1},"add tag":{"milliseconds":10.102709000000004,"n":100000},"add tag kernel":{"milliseconds":8.454207999999994,"n":100000},"add tag from four archetypes":{"milliseconds":10.511167,"n":100000},"add value":{"milliseconds":9.605207999999948,"n":100000},"add several":{"milliseconds":13.698582999999985,"n":100000},"remove tag":{"milliseconds":7.0583330000000615,"n":100000},"remove tag kernel":{"milliseconds":6.0187499999999545,"n":100000},"remove tag from four archetypes":{"milliseconds":7.695917000000009,"n":100000},"remove several":{"milliseconds":7.848124999999982,"n":100000},"add replace":{"milliseconds":7.607708000000002,"n":100000},"emit main":{"milliseconds":8.614332999999988,"n":1000000},"move workers":{"milliseconds":0.019207999999935055,"n":250000},"follow workers":{"milliseconds":0.5898329999999987,"n":1000000},"emit workers":{"milliseconds":6.195291999999995,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.683542000000017,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8422090000000253,"n":100},"wake":{"milliseconds":7.487666000000004,"n":10000},"remote run":{"milliseconds":13.428541999999993,"n":1000},"mailbox resource":{"milliseconds":3.335791999999998,"n":1000},"mailbox select 10K":{"milliseconds":778.8749160000002,"n":1000}}} +{"commit":"f48c8041","dirty":false,"date":"2026-10-18T12:03:21.931Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.91687051375702],[2097152,105.68325198090399],[8388608,61.14703056127776],[33554432,69.363164702976],[134217728,91.304575093711]],"threads":[[524288,740.5115784066595],[2097152,237.5535018873835],[8388608,235.98721653906534],[33554432,238.60929422222222],[134217728,208.0894973138634]],"gpu":354.1364890525686},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.4399999380111694,"n":1000000},"chain main":{"milliseconds":1.5299999713897705,"n":6000000},"sparse changed main":{"milliseconds":0.2999999523162842,"n":1000000},"emit main":{"milliseconds":9.340000033378601,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0649999380111694,"n":100000},"up depth 10 main":{"milliseconds":2.0399999618530273,"n":1000000},"up depth 1000 main":{"milliseconds":2.4149999618530273,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5249999761581421,"n":1000000},"chain workers":{"milliseconds":0.23500001430511475,"n":6000000},"sparse changed workers":{"milliseconds":0.06500005722045898,"n":1000000},"emit workers":{"milliseconds":7.84499990940094,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6949999332427979,"n":100000},"up depth 10 workers":{"milliseconds":0.34499990940093994,"n":1000000},"up depth 1000 workers":{"milliseconds":2.375,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07499992847442627,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6399999856948853,"n":100},"wake":{"milliseconds":7.799999952316284,"n":10000},"loop tick idle":{"milliseconds":45.9449999332428,"n":10000},"remote run":{"milliseconds":14.450000047683716,"n":1000},"mailbox resource":{"milliseconds":5.740000009536743,"n":1000},"mailbox select 10K":{"milliseconds":768.4800000190735,"n":1000},"up depth 10 gpu":{"milliseconds":0.23449999690055848,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.432249999046325,"n":1000000}}} +{"commit":"d4c15d3b","dirty":false,"date":"2026-10-18T13:18:09.353Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,121.80384548019494],[2097152,97.09212623202849],[8388608,65.86432465350966],[33554432,70.14406796490992],[134217728,81.91080103308667]],"threads":[[524288,719.7066215524183],[2097152,245.3239749663664],[8388608,238.29157212605563],[33554432,241.7747162629639],[134217728,214.62319655177473]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005709000000024389,"n":50},"loop tick idle":{"milliseconds":19.580583999999988,"n":10000},"sync point one spawn":{"milliseconds":0.000874999999979309,"n":1},"move 1K":{"milliseconds":0.0006660000000238142,"n":1000},"move 10K":{"milliseconds":0.004332999999974163,"n":10000},"main thread 1K":{"milliseconds":0.006666999999993095,"n":1000},"main thread 10K":{"milliseconds":0.06258300000001782,"n":10000},"up depth 10 10K":{"milliseconds":0.017707999999856838,"n":10000},"create":{"milliseconds":59.119415999999546,"n":1000000},"create flat":{"milliseconds":67.40954200000033,"n":1000000},"create flat dynamic":{"milliseconds":91.78354099999979,"n":1000000},"move":{"milliseconds":0.09041600000000471,"n":250000},"churn":{"milliseconds":14.914792000000034,"n":1000000},"retag":{"milliseconds":33.06608300000016,"n":1000000},"sparse changed":{"milliseconds":0.3190840000000037,"n":1000000},"sparse changed clustered":{"milliseconds":0.022625000000005002,"n":1000000},"follow":{"milliseconds":3.8187909999999192,"n":1000000},"emit":{"milliseconds":8.85633299999995,"n":1000000},"reduce":{"milliseconds":0.5037909999999783,"n":1000000},"chain":{"milliseconds":1.550375000000031,"n":6000000},"up depth 10":{"milliseconds":2.037334000000101,"n":1000000},"up depth 1000":{"milliseconds":2.4054589999999507,"n":1000000},"up one root changed":{"milliseconds":0.02029200000004039,"n":1000000},"up after a spawn":{"milliseconds":2.0809999999999604,"n":1000000},"up filtered after churn":{"milliseconds":0.0418329999999969,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.041333000000008724,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020375000000001364,"n":1000000},"up load":{"milliseconds":218.126666,"n":1000000},"up small tree in a big world":{"milliseconds":0.010832999999990989,"n":1000},"main thread":{"milliseconds":0.330750000000009,"n":50000},"select":{"milliseconds":8.437750000000051,"n":1000000},"spawn bulk":{"milliseconds":2.580291999999986,"n":100000},"spawn bulk kernel":{"milliseconds":1.0354169999999954,"n":100000},"spawn singles":{"milliseconds":0.001125000000001819,"n":4},"spawn sixteen archetypes":{"milliseconds":3.957375000000013,"n":100000},"despawn bulk":{"milliseconds":4.351666000000023,"n":100000},"despawn bulk kernel":{"milliseconds":4.665041999999971,"n":100000},"despawn one":{"milliseconds":0.0015410000000031232,"n":1},"add tag":{"milliseconds":10.050792000000001,"n":100000},"add tag kernel":{"milliseconds":8.582291999999939,"n":100000},"add tag from four archetypes":{"milliseconds":10.485000000000014,"n":100000},"add value":{"milliseconds":9.639916999999969,"n":100000},"add several":{"milliseconds":13.965125000000057,"n":100000},"remove tag":{"milliseconds":7.077624999999983,"n":100000},"remove tag kernel":{"milliseconds":5.916666999999961,"n":100000},"remove tag from four archetypes":{"milliseconds":7.500874999999951,"n":100000},"remove several":{"milliseconds":7.862833000000023,"n":100000},"add replace":{"milliseconds":7.388500000000022,"n":100000},"emit main":{"milliseconds":8.774584000000004,"n":1000000},"move workers":{"milliseconds":0.01954200000000128,"n":250000},"follow workers":{"milliseconds":0.6191670000000045,"n":1000000},"emit workers":{"milliseconds":6.363458999999921,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7086249999999836,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8506249999999795,"n":100},"wake":{"milliseconds":7.349749999999986,"n":10000},"remote run":{"milliseconds":12.458916999999985,"n":1000},"mailbox resource":{"milliseconds":3.4879159999999985,"n":1000},"mailbox select 10K":{"milliseconds":783.8637080000003,"n":1000},"log replay":{"milliseconds":0.24045800000000384,"n":10000},"log rebuild 100K":{"milliseconds":0.4307499999999891,"n":100000}}} +{"commit":"d4c15d3b","dirty":false,"date":"2026-10-18T13:19:13.831Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,116.71107024574674],[2097152,97.25922352446965],[8388608,59.257275429440234],[33554432,69.363164702976],[134217728,82.09035399564198]],"threads":[[524288,666.9204310327561],[2097152,238.08022755443682],[8388608,238.60929422222222],[33554432,240.74928583321605],[134217728,214.3197257572679]],"gpu":337.65466467307465},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.465000033378601,"n":1000000},"chain main":{"milliseconds":1.5500000715255737,"n":6000000},"sparse changed main":{"milliseconds":0.2999999523162842,"n":1000000},"emit main":{"milliseconds":9.27999997138977,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0549999475479126,"n":100000},"up depth 10 main":{"milliseconds":2.0299999713897705,"n":1000000},"up depth 1000 main":{"milliseconds":2.3600000143051147,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5199999809265137,"n":1000000},"chain workers":{"milliseconds":0.23500001430511475,"n":6000000},"sparse changed workers":{"milliseconds":0.05500006675720215,"n":1000000},"emit workers":{"milliseconds":7.759999990463257,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7049999237060547,"n":100000},"up depth 10 workers":{"milliseconds":0.33000004291534424,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3700000047683716,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07999992370605469,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6299999952316284,"n":100},"wake":{"milliseconds":7.559999942779541,"n":10000},"loop tick idle":{"milliseconds":43.94500005245209,"n":10000},"remote run":{"milliseconds":14.710000038146973,"n":1000},"mailbox resource":{"milliseconds":6.1549999713897705,"n":1000},"mailbox select 10K":{"milliseconds":751.5049999952316,"n":1000},"log replay":{"milliseconds":0.36500000953674316,"n":10000},"log rebuild 100K":{"milliseconds":0.5299999713897705,"n":100000},"up depth 10 gpu":{"milliseconds":0.23449999690055848,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.389749997854233,"n":1000000}}} +{"commit":"ba222da0","dirty":false,"date":"2026-10-18T14:42:51.100Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,113.78395095219956],[2097152,105.80129798365581],[8388608,65.15819066690923],[33554432,67.01112826941917],[134217728,87.5737561373463]],"threads":[[524288,884.9515665101467],[2097152,242.8914159088224],[8388608,242.87306582220947],[33554432,239.44735998216112],[134217728,214.03844209525502]]},"results":{"run idle":{"milliseconds":0.00037500000001955414,"n":1},"run 50 idle":{"milliseconds":0.005832999999938693,"n":50},"loop tick idle":{"milliseconds":19.68350000000001,"n":10000},"sync point one spawn":{"milliseconds":0.0008749999999508873,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.004333999999971638,"n":10000},"main thread 1K":{"milliseconds":0.00666599999999562,"n":1000},"main thread 10K":{"milliseconds":0.06245799999999235,"n":10000},"up depth 10 10K":{"milliseconds":0.017749999999978172,"n":10000},"create":{"milliseconds":60.14874999999938,"n":1000000},"create flat":{"milliseconds":67.12504200000012,"n":1000000},"create flat dynamic":{"milliseconds":96.35575000000063,"n":1000000},"move":{"milliseconds":0.09162500000002183,"n":250000},"churn":{"milliseconds":14.802875000000085,"n":1000000},"retag":{"milliseconds":34.068084,"n":1000000},"sparse changed":{"milliseconds":0.3160419999999817,"n":1000000},"sparse changed clustered":{"milliseconds":0.02295800000001691,"n":1000000},"follow":{"milliseconds":3.891375000000039,"n":1000000},"emit":{"milliseconds":8.848958000000039,"n":1000000},"reduce":{"milliseconds":0.5133749999999964,"n":1000000},"chain":{"milliseconds":1.5632080000000315,"n":6000000},"up depth 10":{"milliseconds":2.075292000000104,"n":1000000},"up depth 1000":{"milliseconds":2.429457999999954,"n":1000000},"up one root changed":{"milliseconds":0.022250000000099135,"n":1000000},"up after a spawn":{"milliseconds":2.079499999999996,"n":1000000},"up filtered after churn":{"milliseconds":0.044791000000032,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04333300000007512,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019834000000003016,"n":1000000},"up load":{"milliseconds":218.607708,"n":1000000},"up small tree in a big world":{"milliseconds":0.011500000000012278,"n":1000},"main thread":{"milliseconds":0.34987499999999727,"n":50000},"select":{"milliseconds":8.278958000000102,"n":1000000},"spawn bulk":{"milliseconds":2.555874999999986,"n":100000},"spawn bulk kernel":{"milliseconds":1.0370829999999955,"n":100000},"spawn singles":{"milliseconds":0.001165999999898304,"n":4},"spawn sixteen archetypes":{"milliseconds":4.0485839999999484,"n":100000},"despawn bulk":{"milliseconds":4.593833000000018,"n":100000},"despawn bulk kernel":{"milliseconds":4.537958000000003,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":10.085958000000005,"n":100000},"add tag kernel":{"milliseconds":8.390708000000018,"n":100000},"add tag from four archetypes":{"milliseconds":10.556500000000028,"n":100000},"add value":{"milliseconds":9.694917000000032,"n":100000},"add several":{"milliseconds":14.154249999999934,"n":100000},"remove tag":{"milliseconds":7.081458999999995,"n":100000},"remove tag kernel":{"milliseconds":5.947749999999985,"n":100000},"remove tag from four archetypes":{"milliseconds":7.8633330000000115,"n":100000},"remove several":{"milliseconds":7.748334,"n":100000},"add replace":{"milliseconds":7.7454589999999826,"n":100000},"emit main":{"milliseconds":8.814333000000033,"n":1000000},"move workers":{"milliseconds":0.019375000000081855,"n":250000},"follow workers":{"milliseconds":0.5991249999999582,"n":1000000},"emit workers":{"milliseconds":6.180749999999989,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7034170000000017,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8872910000000047,"n":100},"wake":{"milliseconds":7.89725,"n":10000},"remote run":{"milliseconds":13.527125000000012,"n":1000},"mailbox resource":{"milliseconds":5.564833000000007,"n":1000},"mailbox select 10K":{"milliseconds":781.9058749999999,"n":1000},"log replay":{"milliseconds":0.2065419999999989,"n":10000},"log rebuild 100K":{"milliseconds":0.334541999999999,"n":100000},"handoff":{"milliseconds":11.253165999999993,"n":1000},"fused tiers":{"milliseconds":7.000042000000008,"n":1000}}} +{"commit":"ba222da0","dirty":false,"date":"2026-10-18T14:43:45.987Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,110.46726280885375],[2097152,98.68950484429067],[8388608,67.108864],[33554432,87.15437099004896],[134217728,83.10695183122623]],"threads":[[524288,610.080585123967],[2097152,239.1407175321551],[8388608,239.1407175321551],[33554432,233.42212839319492],[134217728,198.10735352732144]],"gpu":331.1973588111923},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.565000057220459,"n":1000000},"chain main":{"milliseconds":1.5549999475479126,"n":6000000},"sparse changed main":{"milliseconds":0.31999993324279785,"n":1000000},"emit main":{"milliseconds":9.514999985694885,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.1050000190734863,"n":100000},"up depth 10 main":{"milliseconds":2.024999976158142,"n":1000000},"up depth 1000 main":{"milliseconds":2.3949999809265137,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5250000953674316,"n":1000000},"chain workers":{"milliseconds":0.2399998903274536,"n":6000000},"sparse changed workers":{"milliseconds":0.060000061988830566,"n":1000000},"emit workers":{"milliseconds":8.035000085830688,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7249999046325684,"n":100000},"up depth 10 workers":{"milliseconds":0.3399999141693115,"n":1000000},"up depth 1000 workers":{"milliseconds":2.399999976158142,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07999992370605469,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6349999904632568,"n":100},"wake":{"milliseconds":8.009999990463257,"n":10000},"loop tick idle":{"milliseconds":44.955000042915344,"n":10000},"remote run":{"milliseconds":15.100000023841858,"n":1000},"mailbox resource":{"milliseconds":8.754999995231628,"n":1000},"mailbox select 10K":{"milliseconds":765.2050000429153,"n":1000},"log replay":{"milliseconds":0.31499993801116943,"n":10000},"log rebuild 100K":{"milliseconds":0.3349999189376831,"n":100000},"handoff":{"milliseconds":12.640000104904175,"n":1000},"fused tiers":{"milliseconds":8.799999952316284,"n":1000},"up depth 10 gpu":{"milliseconds":0.2340000033378601,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.453000003099442,"n":1000000}}} +{"commit":"71b7652e","dirty":false,"date":"2026-10-18T16:13:10.804Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,124.69420787365011],[2097152,110.91989203680521],[8388608,73.87284650842751],[33554432,68.12363364680279],[134217728,85.89472821941165]],"threads":[[524288,762.4198691503751],[2097152,246.96959933463185],[8388608,236.3595712982575],[33554432,230.42085393814202],[134217728,211.05150226571297]]},"results":{"run idle":{"milliseconds":0.00041599999985919567,"n":1},"run 50 idle":{"milliseconds":0.005625000000009095,"n":50},"loop tick idle":{"milliseconds":19.249875000000003,"n":10000},"sync point one spawn":{"milliseconds":0.0008750000000645741,"n":1},"move 1K":{"milliseconds":0.0007080000000314612,"n":1000},"move 10K":{"milliseconds":0.004290999999966516,"n":10000},"main thread 1K":{"milliseconds":0.00666599999999562,"n":1000},"main thread 10K":{"milliseconds":0.06270800000004328,"n":10000},"up depth 10 10K":{"milliseconds":0.017709000000081687,"n":10000},"create":{"milliseconds":61.04745799999955,"n":1000000},"create flat":{"milliseconds":68.30104099999971,"n":1000000},"create flat dynamic":{"milliseconds":96.42641699999967,"n":1000000},"move":{"milliseconds":0.09029199999997672,"n":250000},"churn":{"milliseconds":14.938249999999925,"n":1000000},"retag":{"milliseconds":37.037874999999985,"n":1000000},"sparse changed":{"milliseconds":0.33499999999997954,"n":1000000},"sparse changed clustered":{"milliseconds":0.02379099999998857,"n":1000000},"follow":{"milliseconds":3.822167000000036,"n":1000000},"emit":{"milliseconds":8.72345800000005,"n":1000000},"reduce":{"milliseconds":0.5038749999999936,"n":1000000},"chain":{"milliseconds":1.562750000000051,"n":6000000},"up depth 10":{"milliseconds":2.0553750000000264,"n":1000000},"up depth 1000":{"milliseconds":2.3886250000000473,"n":1000000},"up one root changed":{"milliseconds":0.022457999999915046,"n":1000000},"up after a spawn":{"milliseconds":2.1164170000000126,"n":1000000},"up filtered after churn":{"milliseconds":0.04258299999992232,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.03933400000005349,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019208999999989373,"n":1000000},"up load":{"milliseconds":229.11233300000004,"n":1000000},"up small tree in a big world":{"milliseconds":0.010625000000004547,"n":1000},"main thread":{"milliseconds":0.3465840000000071,"n":50000},"select":{"milliseconds":8.370625000000018,"n":1000000},"spawn bulk":{"milliseconds":2.5370840000000214,"n":100000},"spawn bulk kernel":{"milliseconds":1.031958000000003,"n":100000},"spawn singles":{"milliseconds":0.001125000000001819,"n":4},"spawn sixteen archetypes":{"milliseconds":4.033082999999976,"n":100000},"despawn bulk":{"milliseconds":4.449208000000056,"n":100000},"despawn bulk kernel":{"milliseconds":4.8077089999999885,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":9.713624999999979,"n":100000},"add tag kernel":{"milliseconds":8.319583000000023,"n":100000},"add tag from four archetypes":{"milliseconds":9.703750000000014,"n":100000},"add value":{"milliseconds":9.796166999999969,"n":100000},"add several":{"milliseconds":13.04358400000001,"n":100000},"remove tag":{"milliseconds":6.986374999999953,"n":100000},"remove tag kernel":{"milliseconds":5.866749999999968,"n":100000},"remove tag from four archetypes":{"milliseconds":7.537374999999997,"n":100000},"remove several":{"milliseconds":7.939499999999953,"n":100000},"add replace":{"milliseconds":7.511375000000044,"n":100000},"emit main":{"milliseconds":8.630125000000135,"n":1000000},"move workers":{"milliseconds":0.019209000000046217,"n":250000},"follow workers":{"milliseconds":0.5855000000000246,"n":1000000},"emit workers":{"milliseconds":6.13145899999995,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6748340000000326,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8378329999999892,"n":100},"wake":{"milliseconds":7.298374999999993,"n":10000},"remote run":{"milliseconds":13.626249999999999,"n":1000},"mailbox resource":{"milliseconds":5.3802499999999895,"n":1000},"mailbox select 10K":{"milliseconds":782.0977500000004,"n":1000},"log replay":{"milliseconds":0.2136249999999933,"n":10000},"log rebuild 100K":{"milliseconds":0.3350410000000039,"n":100000},"handoff":{"milliseconds":10.960459000000014,"n":1000},"fused tiers":{"milliseconds":6.615166999999985,"n":1000},"despawn objects 10K":{"milliseconds":0.8490419999999972,"n":10000}}} +{"commit":"71b7652e","dirty":false,"date":"2026-10-18T16:14:04.228Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,117.22072509677545],[2097152,118.25350583943023],[8388608,77.35892571485549],[33554432,73.54396343028722],[134217728,96.55951754049998]],"threads":[[524288,735.4395862638398],[2097152,242.3796405912897],[8388608,245.70750424204996],[33554432,238.08022755443682],[134217728,208.49355419737967]],"gpu":344.5898039980819},"results":{"move main":{"milliseconds":0.0849999189376831,"n":250000},"follow main":{"milliseconds":3.490000009536743,"n":1000000},"chain main":{"milliseconds":1.5549999475479126,"n":6000000},"sparse changed main":{"milliseconds":0.3049999475479126,"n":1000000},"emit main":{"milliseconds":9.304999947547913,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0799999237060547,"n":100000},"up depth 10 main":{"milliseconds":2.0399999618530273,"n":1000000},"up depth 1000 main":{"milliseconds":2.375,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5199999809265137,"n":1000000},"chain workers":{"milliseconds":0.2349998950958252,"n":6000000},"sparse changed workers":{"milliseconds":0.06499993801116943,"n":1000000},"emit workers":{"milliseconds":7.715000033378601,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6850000619888306,"n":100000},"up depth 10 workers":{"milliseconds":0.3399999141693115,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3550000190734863,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.0700000524520874,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6100000143051147,"n":100},"wake":{"milliseconds":7.490000009536743,"n":10000},"loop tick idle":{"milliseconds":44.71000003814697,"n":10000},"remote run":{"milliseconds":14.365000009536743,"n":1000},"mailbox resource":{"milliseconds":6.180000066757202,"n":1000},"mailbox select 10K":{"milliseconds":750.5600000619888,"n":1000},"log replay":{"milliseconds":0.31999993324279785,"n":10000},"log rebuild 100K":{"milliseconds":0.3700000047683716,"n":100000},"handoff":{"milliseconds":11.705000042915344,"n":1000},"fused tiers":{"milliseconds":8.355000019073486,"n":1000},"despawn objects 10K":{"milliseconds":1.024999976158142,"n":10000},"up depth 10 gpu":{"milliseconds":0.23225000500679016,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.227500003576279,"n":1000000}}} +{"commit":"3fdcd402","dirty":false,"date":"2026-10-18T18:13:05.831Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,123.62230728704256],[2097152,104.96010009775152],[8388608,73.09171308266876],[33554432,70.02969764419323],[134217728,97.20639362665176]],"threads":[[524288,815.3444815850422],[2097152,250.43062126858197],[8388608,244.1386549864243],[33554432,236.22946608544518],[134217728,211.24873377676317]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.00566699999998832,"n":50},"loop tick idle":{"milliseconds":18.47274999999999,"n":10000},"sync point one spawn":{"milliseconds":0.0008750000000077307,"n":1},"move 1K":{"milliseconds":0.0007079999999746178,"n":1000},"move 10K":{"milliseconds":0.004332999999974163,"n":10000},"main thread 1K":{"milliseconds":0.00666599999999562,"n":1000},"main thread 10K":{"milliseconds":0.06279100000000426,"n":10000},"up depth 10 10K":{"milliseconds":0.017666999999846666,"n":10000},"create":{"milliseconds":57.06325000000015,"n":1000000},"create flat":{"milliseconds":63.90454099999988,"n":1000000},"create flat dynamic":{"milliseconds":90.9657500000003,"n":1000000},"move":{"milliseconds":0.09008299999993596,"n":250000},"churn":{"milliseconds":14.33254199999999,"n":1000000},"retag":{"milliseconds":35.629459,"n":1000000},"sparse changed":{"milliseconds":0.32091599999998266,"n":1000000},"sparse changed clustered":{"milliseconds":0.022625000000005002,"n":1000000},"follow":{"milliseconds":3.869291999999973,"n":1000000},"emit":{"milliseconds":8.775000000000091,"n":1000000},"reduce":{"milliseconds":0.5038750000001073,"n":1000000},"chain":{"milliseconds":1.5506249999999682,"n":6000000},"up depth 10":{"milliseconds":2.035291000000143,"n":1000000},"up depth 1000":{"milliseconds":2.38350000000014,"n":1000000},"up one root changed":{"milliseconds":0.020041999999989457,"n":1000000},"up after a spawn":{"milliseconds":2.0852089999999635,"n":1000000},"up filtered after churn":{"milliseconds":0.04216699999994944,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.038625000000024556,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019374999999968168,"n":1000000},"up load":{"milliseconds":209.849,"n":1000000},"up small tree in a big world":{"milliseconds":0.010750000000001592,"n":1000},"main thread":{"milliseconds":0.33183400000000063,"n":50000},"select":{"milliseconds":8.158583000000135,"n":1000000},"spawn bulk":{"milliseconds":2.5320409999999924,"n":100000},"spawn bulk kernel":{"milliseconds":1.034667000000013,"n":100000},"spawn singles":{"milliseconds":0.001125000000001819,"n":4},"spawn sixteen archetypes":{"milliseconds":3.9927079999999933,"n":100000},"despawn bulk":{"milliseconds":4.378792000000004,"n":100000},"despawn bulk kernel":{"milliseconds":4.543333000000018,"n":100000},"despawn one":{"milliseconds":0.0015409999999960178,"n":1},"add tag":{"milliseconds":9.544333000000051,"n":100000},"add tag kernel":{"milliseconds":8.491832999999986,"n":100000},"add tag from four archetypes":{"milliseconds":9.77358300000003,"n":100000},"add value":{"milliseconds":9.659166000000027,"n":100000},"add several":{"milliseconds":13.013416000000007,"n":100000},"remove tag":{"milliseconds":7.027917000000002,"n":100000},"remove tag kernel":{"milliseconds":6.028707999999938,"n":100000},"remove tag from four archetypes":{"milliseconds":7.775999999999954,"n":100000},"remove several":{"milliseconds":7.824375000000032,"n":100000},"add replace":{"milliseconds":7.711457999999993,"n":100000},"emit main":{"milliseconds":8.704417000000035,"n":1000000},"move workers":{"milliseconds":0.019415999999864653,"n":250000},"follow workers":{"milliseconds":0.586667000000034,"n":1000000},"emit workers":{"milliseconds":6.293665999999803,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7091660000000104,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8989169999999831,"n":100},"wake":{"milliseconds":7.0971250000000055,"n":10000},"remote run":{"milliseconds":9.375416999999999,"n":1000},"mailbox resource":{"milliseconds":4.976833999999997,"n":1000},"mailbox select 10K":{"milliseconds":745.675542,"n":1000},"log replay":{"milliseconds":0.20266600000000778,"n":10000},"log rebuild 100K":{"milliseconds":0.3060000000000045,"n":100000},"handoff":{"milliseconds":10.446832999999998,"n":1000},"fused tiers":{"milliseconds":6.329624999999993,"n":1000},"despawn objects 10K":{"milliseconds":0.8090409999999792,"n":10000},"tail prompt":{"milliseconds":17.336000000000013,"n":1000},"tail late snapshot 10K":{"milliseconds":0.07024999999998727,"n":10000}}} +{"commit":"3fdcd402","dirty":false,"date":"2026-10-18T18:13:58.160Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.37464394719379],[2097152,107.3741824],[8388608,68.82960410009862],[33554432,67.786731313769],[134217728,94.18787930286241]],"threads":[[524288,813.4407757174013],[2097152,239.67451428671077],[8388608,240.2106988829332],[33554432,233.93068061761622],[134217728,208.89918755280172]],"gpu":356.0151936321933},"results":{"move main":{"milliseconds":0.0849999999627471,"n":250000},"follow main":{"milliseconds":3.48499999998603,"n":1000000},"chain main":{"milliseconds":1.5499999999301508,"n":6000000},"sparse changed main":{"milliseconds":0.2999999999301508,"n":1000000},"emit main":{"milliseconds":9.29999999993015,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.040000000037253,"n":100000},"up depth 10 main":{"milliseconds":2.040000000037253,"n":1000000},"up depth 1000 main":{"milliseconds":2.35999999998603,"n":1000000},"move workers":{"milliseconds":0.014999999897554517,"n":250000},"follow workers":{"milliseconds":0.5199999999022111,"n":1000000},"chain workers":{"milliseconds":0.23499999998603016,"n":6000000},"sparse changed workers":{"milliseconds":0.05499999993480742,"n":1000000},"emit workers":{"milliseconds":7.694999999948777,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6649999999208376,"n":100000},"up depth 10 workers":{"milliseconds":0.3399999999674037,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3850000000093132,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.06999999994877726,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.5949999999720603,"n":100},"wake":{"milliseconds":7.394999999902211,"n":10000},"loop tick idle":{"milliseconds":43.13500000000931,"n":10000},"remote run":{"milliseconds":14.755000000004657,"n":1000},"mailbox resource":{"milliseconds":6.135000000009313,"n":1000},"mailbox select 10K":{"milliseconds":720.2749999999069,"n":1000},"log replay":{"milliseconds":0.32499999995343387,"n":10000},"log rebuild 100K":{"milliseconds":0.3349999999627471,"n":100000},"handoff":{"milliseconds":11.43999999994412,"n":1000},"fused tiers":{"milliseconds":8.635000000009313,"n":1000},"despawn objects 10K":{"milliseconds":1.0649999999441206,"n":10000},"tail prompt":{"milliseconds":18.755000000004657,"n":1000},"tail late snapshot 10K":{"milliseconds":0.04999999993015081,"n":10000},"up depth 10 gpu":{"milliseconds":0.23574999999837018,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.260999999998603,"n":1000000}}} +{"commit":"eb4a7301","dirty":true,"date":"2026-10-18T19:00:35.558Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,111.04607638001282],[2097152,109.00932223350229],[8388608,78.43637003284877],[33554432,71.55893528823673],[134217728,94.49177811867825]],"threads":[[524288,762.0140630366923],[2097152,236.16025984754756],[8388608,233.79906039823686],[33554432,229.1097913148623],[134217728,204.0590187836436]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005750000000034561,"n":50},"loop tick idle":{"milliseconds":18.863417000000027,"n":10000},"sync point one spawn":{"milliseconds":0.0009159999999894808,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.004457999999999629,"n":10000},"main thread 1K":{"milliseconds":0.006750000000010914,"n":1000},"main thread 10K":{"milliseconds":0.06433299999997644,"n":10000},"up depth 10 10K":{"milliseconds":0.018124999999940883,"n":10000},"create":{"milliseconds":58.26712499999985,"n":1000000},"create flat":{"milliseconds":66.17375000000038,"n":1000000},"create flat dynamic":{"milliseconds":92.61475000000019,"n":1000000},"move":{"milliseconds":0.09104100000001836,"n":250000},"churn":{"milliseconds":14.84575000000018,"n":1000000},"retag":{"milliseconds":32.84016699999984,"n":1000000},"sparse changed":{"milliseconds":0.42420800000002146,"n":1000000},"sparse changed clustered":{"milliseconds":0.025334000000015067,"n":1000000},"follow":{"milliseconds":3.9452499999999873,"n":1000000},"emit":{"milliseconds":9.009374999999977,"n":1000000},"reduce":{"milliseconds":0.5039160000000038,"n":1000000},"chain":{"milliseconds":1.545375000000007,"n":6000000},"up depth 10":{"milliseconds":2.0995419999999285,"n":1000000},"up depth 1000":{"milliseconds":2.395915999999943,"n":1000000},"up one root changed":{"milliseconds":0.03049999999996089,"n":1000000},"up after a spawn":{"milliseconds":2.4484170000000063,"n":1000000},"up filtered after churn":{"milliseconds":0.0500409999999647,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04783400000007987,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020584000000042124,"n":1000000},"up load":{"milliseconds":210.758375,"n":1000000},"up small tree in a big world":{"milliseconds":0.011209000000008018,"n":1000},"main thread":{"milliseconds":0.33858299999999986,"n":50000},"select":{"milliseconds":8.252749999999992,"n":1000000},"spawn bulk":{"milliseconds":2.581333000000029,"n":100000},"spawn bulk kernel":{"milliseconds":1.0330000000000155,"n":100000},"spawn singles":{"milliseconds":0.001165999999983569,"n":4},"spawn sixteen archetypes":{"milliseconds":4.222959000000003,"n":100000},"despawn bulk":{"milliseconds":4.561208000000022,"n":100000},"despawn bulk kernel":{"milliseconds":4.733332999999959,"n":100000},"despawn one":{"milliseconds":0.0015830000000036648,"n":1},"add tag":{"milliseconds":9.622041999999965,"n":100000},"add tag kernel":{"milliseconds":8.576000000000022,"n":100000},"add tag from four archetypes":{"milliseconds":9.929499999999962,"n":100000},"add value":{"milliseconds":9.727000000000032,"n":100000},"add several":{"milliseconds":13.436541000000034,"n":100000},"remove tag":{"milliseconds":7.283374999999978,"n":100000},"remove tag kernel":{"milliseconds":6.119666999999936,"n":100000},"remove tag from four archetypes":{"milliseconds":8.100000000000023,"n":100000},"remove several":{"milliseconds":8.094957999999906,"n":100000},"add replace":{"milliseconds":8.147041999999942,"n":100000},"emit main":{"milliseconds":9.128917000000001,"n":1000000},"move workers":{"milliseconds":0.02129099999990558,"n":250000},"follow workers":{"milliseconds":0.6476670000000695,"n":1000000},"emit workers":{"milliseconds":6.227083999999991,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6990830000000301,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.9718330000000037,"n":100},"wake":{"milliseconds":7.420750000000012,"n":10000},"remote run":{"milliseconds":9.987707999999998,"n":1000},"mailbox resource":{"milliseconds":5.297291999999999,"n":1000},"mailbox select 10K":{"milliseconds":770.2945,"n":1000},"log replay":{"milliseconds":0.20795800000000497,"n":10000},"log rebuild 100K":{"milliseconds":0.3222499999999968,"n":100000},"handoff":{"milliseconds":10.856417000000022,"n":1000},"fused tiers":{"milliseconds":6.89304199999998,"n":1000},"despawn objects 10K":{"milliseconds":0.8365410000000111,"n":10000},"tail prompt":{"milliseconds":20.4315,"n":1000},"tail late snapshot 10K":{"milliseconds":0.0822909999999979,"n":10000}}} +{"commit":"eb4a7301","dirty":true,"date":"2026-10-18T19:01:27.933Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.37464397233003],[2097152,124.85370049756625],[8388608,65.95465748902801],[33554432,86.03700515080538],[134217728,96.21342508832106]],"threads":[[524288,807.3246796540222],[2097152,238.08022701363316],[8388608,243.47887164710178],[33554432,234.4414462843958],[134217728,206.88667131173406]],"gpu":336.3852832088052},"results":{"move main":{"milliseconds":0.0849999999627471,"n":250000},"follow main":{"milliseconds":3.5,"n":1000000},"chain main":{"milliseconds":1.540000000037253,"n":6000000},"sparse changed main":{"milliseconds":0.2949999999254942,"n":1000000},"emit main":{"milliseconds":9.305000000167638,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0600000000558794,"n":100000},"up depth 10 main":{"milliseconds":2.044999999925494,"n":1000000},"up depth 1000 main":{"milliseconds":2.385000000242144,"n":1000000},"move workers":{"milliseconds":0.014999999664723873,"n":250000},"follow workers":{"milliseconds":0.5199999995529652,"n":1000000},"chain workers":{"milliseconds":0.23499999986961484,"n":6000000},"sparse changed workers":{"milliseconds":0.060000000055879354,"n":1000000},"emit workers":{"milliseconds":7.824999999720603,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6949999998323619,"n":100000},"up depth 10 workers":{"milliseconds":0.3299999996088445,"n":1000000},"up depth 1000 workers":{"milliseconds":2.400000000372529,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07999999960884452,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6150000002235174,"n":100},"wake":{"milliseconds":7.444999999832362,"n":10000},"loop tick idle":{"milliseconds":44.6999999997206,"n":10000},"remote run":{"milliseconds":15.59000000031665,"n":1000},"mailbox resource":{"milliseconds":6.014999999664724,"n":1000},"mailbox select 10K":{"milliseconds":746.0049999998882,"n":1000},"log replay":{"milliseconds":0.3300000000745058,"n":10000},"log rebuild 100K":{"milliseconds":0.35499999998137355,"n":100000},"handoff":{"milliseconds":13.705000000074506,"n":1000},"fused tiers":{"milliseconds":9.299999999813735,"n":1000},"despawn objects 10K":{"milliseconds":1.040000000037253,"n":10000},"tail prompt":{"milliseconds":22.84499999973923,"n":1000},"tail late snapshot 10K":{"milliseconds":0.060000000055879354,"n":10000},"up depth 10 gpu":{"milliseconds":0.23150000001769513,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.555749999987893,"n":1000000}}} +{"commit":"3c43e8ac","dirty":false,"date":"2026-10-18T19:25:13.879Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,106.55371876550562],[2097152,83.28533248611099],[8388608,57.1666417500735],[33554432,65.76076825085798],[134217728,81.75493327680712]],"threads":[[524288,661.7488817206065],[2097152,233.84152534436808],[8388608,231.90968120950347],[33554432,231.0240060244217],[134217728,205.7436247253691]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005708000000026914,"n":50},"loop tick idle":{"milliseconds":18.996916999999996,"n":10000},"sync point one spawn":{"milliseconds":0.0009159999999610591,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.004333999999971638,"n":10000},"main thread 1K":{"milliseconds":0.006708000000003267,"n":1000},"main thread 10K":{"milliseconds":0.062958999999978,"n":10000},"up depth 10 10K":{"milliseconds":0.01766700000007404,"n":10000},"create":{"milliseconds":58.78783299999941,"n":1000000},"create flat":{"milliseconds":66.03333300000031,"n":1000000},"create flat dynamic":{"milliseconds":92.49604199999976,"n":1000000},"move":{"milliseconds":0.09062499999998863,"n":250000},"churn":{"milliseconds":14.40970900000002,"n":1000000},"retag":{"milliseconds":32.90487499999995,"n":1000000},"sparse changed":{"milliseconds":0.5190000000000055,"n":1000000},"sparse changed clustered":{"milliseconds":0.02616699999998673,"n":1000000},"follow":{"milliseconds":3.9168339999999944,"n":1000000},"emit":{"milliseconds":9.03083399999997,"n":1000000},"reduce":{"milliseconds":0.5040000000000191,"n":1000000},"chain":{"milliseconds":1.5612499999999727,"n":6000000},"up depth 10":{"milliseconds":2.0690419999999676,"n":1000000},"up depth 1000":{"milliseconds":2.6207079999999223,"n":1000000},"up one root changed":{"milliseconds":0.03508300000009967,"n":1000000},"up after a spawn":{"milliseconds":2.697249999999997,"n":1000000},"up filtered after churn":{"milliseconds":0.055375000000026375,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.05187499999999545,"n":1000000},"up filtered after a reparent":{"milliseconds":0.020624999999995453,"n":1000000},"up load":{"milliseconds":211.48291700000004,"n":1000000},"up small tree in a big world":{"milliseconds":0.011291999999997415,"n":1000},"main thread":{"milliseconds":0.3525419999999997,"n":50000},"select":{"milliseconds":8.20958399999995,"n":1000000},"spawn bulk":{"milliseconds":2.5792090000000485,"n":100000},"spawn bulk kernel":{"milliseconds":1.0417909999999893,"n":100000},"spawn singles":{"milliseconds":0.001165999999898304,"n":4},"spawn sixteen archetypes":{"milliseconds":4.26724999999999,"n":100000},"despawn bulk":{"milliseconds":4.653999999999996,"n":100000},"despawn bulk kernel":{"milliseconds":4.730957999999987,"n":100000},"despawn one":{"milliseconds":0.0015410000000031232,"n":1},"add tag":{"milliseconds":9.596708000000035,"n":100000},"add tag kernel":{"milliseconds":8.521208000000001,"n":100000},"add tag from four archetypes":{"milliseconds":9.914041999999995,"n":100000},"add value":{"milliseconds":9.734708999999953,"n":100000},"add several":{"milliseconds":13.275709000000006,"n":100000},"remove tag":{"milliseconds":7.063125000000014,"n":100000},"remove tag kernel":{"milliseconds":6.074541999999951,"n":100000},"remove tag from four archetypes":{"milliseconds":7.440458999999919,"n":100000},"remove several":{"milliseconds":7.868333000000007,"n":100000},"add replace":{"milliseconds":7.817125000000033,"n":100000},"emit main":{"milliseconds":9.05233300000009,"n":1000000},"move workers":{"milliseconds":0.020624999999995453,"n":250000},"follow workers":{"milliseconds":0.6428750000000036,"n":1000000},"emit workers":{"milliseconds":6.1006660000000466,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7072079999999801,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.9642910000000029,"n":100},"wake":{"milliseconds":7.463250000000002,"n":10000},"remote run":{"milliseconds":10.372874999999993,"n":1000},"mailbox resource":{"milliseconds":5.294000000000011,"n":1000},"mailbox select 10K":{"milliseconds":767.7134590000001,"n":1000},"log replay":{"milliseconds":0.2049999999999983,"n":10000},"log rebuild 100K":{"milliseconds":0.30033299999999485,"n":100000},"handoff":{"milliseconds":10.871207999999996,"n":1000},"fused tiers":{"milliseconds":6.89387499999998,"n":1000},"despawn objects 10K":{"milliseconds":0.8149579999999901,"n":10000},"tail prompt":{"milliseconds":20.084208000000018,"n":1000},"tail late snapshot 10K":{"milliseconds":0.08729199999999082,"n":10000},"tail overlap":{"milliseconds":250.65566599999988,"n":100},"tail overlap waiting":{"milliseconds":469.9517079999998,"n":100}}} +{"commit":"3c43e8ac","dirty":false,"date":"2026-10-18T19:26:32.778Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.91687203554906],[2097152,125.43712893702507],[8388608,123.13553029206295],[33554432,74.77310752399501],[134217728,95.86980575255102]],"threads":[[524288,838.86079921875],[2097152,243.47887164710178],[8388608,240.74928794466007],[33554432,233.93068061761622],[134217728,206.4888122633136]],"gpu":337.01877715633645},"results":{"move main":{"milliseconds":0.0849999999627471,"n":250000},"follow main":{"milliseconds":3.4900000002235174,"n":1000000},"chain main":{"milliseconds":1.5549999997019768,"n":6000000},"sparse changed main":{"milliseconds":0.2949999999254942,"n":1000000},"emit main":{"milliseconds":9.340000000782311,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0600000005215406,"n":100000},"up depth 10 main":{"milliseconds":2.084999999962747,"n":1000000},"up depth 1000 main":{"milliseconds":2.389999999664724,"n":1000000},"move workers":{"milliseconds":0.014999999664723873,"n":250000},"follow workers":{"milliseconds":0.5199999995529652,"n":1000000},"chain workers":{"milliseconds":0.23499999940395355,"n":6000000},"sparse changed workers":{"milliseconds":0.05000000074505806,"n":1000000},"emit workers":{"milliseconds":7.790000000037253,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7099999999627471,"n":100000},"up depth 10 workers":{"milliseconds":0.31499999947845936,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3849999997764826,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07500000018626451,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.5899999998509884,"n":100},"wake":{"milliseconds":7.490000000223517,"n":10000},"loop tick idle":{"milliseconds":42.375,"n":10000},"remote run":{"milliseconds":15.230000000447035,"n":1000},"mailbox resource":{"milliseconds":6.320000000298023,"n":1000},"mailbox select 10K":{"milliseconds":739.7099999999627,"n":1000},"log replay":{"milliseconds":0.3049999997019768,"n":10000},"log rebuild 100K":{"milliseconds":0.35000000055879354,"n":100000},"handoff":{"milliseconds":13.729999999515712,"n":1000},"fused tiers":{"milliseconds":9.915000000037253,"n":1000},"despawn objects 10K":{"milliseconds":1.0199999995529652,"n":10000},"tail prompt":{"milliseconds":24.87999999988824,"n":1000},"tail late snapshot 10K":{"milliseconds":0.06999999936670065,"n":10000},"tail overlap":{"milliseconds":253.10500000044703,"n":100},"tail overlap waiting":{"milliseconds":481.87999999988824,"n":100},"up depth 10 gpu":{"milliseconds":0.23525000000372528,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.541000000014901,"n":1000000}}} +{"commit":"dcec1e13","dirty":true,"date":"2026-10-18T20:11:11.832Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,119.48615986418537],[2097152,103.88031348826105],[8388608,66.78608684229134],[33554432,82.46018872410623],[134217728,95.00176105187485]],"threads":[[524288,752.886987910231],[2097152,236.01750730203574],[8388608,233.2193362293694],[33554432,231.0323570407121],[134217728,204.42490699667053]]},"results":{"run idle":{"milliseconds":0.0003749999999627107,"n":1},"run 50 idle":{"milliseconds":0.005749999999920874,"n":50},"loop tick idle":{"milliseconds":19.875249999999994,"n":10000},"sync point one spawn":{"milliseconds":0.0008750000000077307,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.00437499999998181,"n":10000},"main thread 1K":{"milliseconds":0.006666999999993095,"n":1000},"main thread 10K":{"milliseconds":0.06370800000001964,"n":10000},"up depth 10 10K":{"milliseconds":0.018041000000039276,"n":10000},"create":{"milliseconds":59.02654200000052,"n":1000000},"create flat":{"milliseconds":66.85808299999917,"n":1000000},"create flat dynamic":{"milliseconds":94.19399999999996,"n":1000000},"move":{"milliseconds":0.09145799999987503,"n":250000},"churn":{"milliseconds":14.60287500000004,"n":1000000},"retag":{"milliseconds":33.509457999999995,"n":1000000},"sparse changed":{"milliseconds":0.3221670000000074,"n":1000000},"sparse changed clustered":{"milliseconds":0.022999999999996135,"n":1000000},"follow":{"milliseconds":3.9080419999999663,"n":1000000},"emit":{"milliseconds":8.829750000000104,"n":1000000},"reduce":{"milliseconds":0.5133329999999887,"n":1000000},"chain":{"milliseconds":1.5408330000000205,"n":6000000},"up depth 10":{"milliseconds":2.0798749999999018,"n":1000000},"up depth 1000":{"milliseconds":2.4797079999999596,"n":1000000},"up one root changed":{"milliseconds":0.020541999999977634,"n":1000000},"up after a spawn":{"milliseconds":2.1190839999999866,"n":1000000},"up filtered after churn":{"milliseconds":0.04099999999993997,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.039750000000026375,"n":1000000},"up filtered after a reparent":{"milliseconds":0.0206670000000031,"n":1000000},"up load":{"milliseconds":215.467417,"n":1000000},"up small tree in a big world":{"milliseconds":0.011458000000004631,"n":1000},"main thread":{"milliseconds":0.3320839999999947,"n":50000},"select":{"milliseconds":8.240666999999803,"n":1000000},"spawn bulk":{"milliseconds":2.550624999999968,"n":100000},"spawn bulk kernel":{"milliseconds":1.0443750000000023,"n":100000},"spawn singles":{"milliseconds":0.001125000000001819,"n":4},"spawn sixteen archetypes":{"milliseconds":4.358791999999994,"n":100000},"despawn bulk":{"milliseconds":4.76112500000005,"n":100000},"despawn bulk kernel":{"milliseconds":5.418084000000022,"n":100000},"despawn one":{"milliseconds":0.0016249999999899956,"n":1},"add tag":{"milliseconds":10.222833000000037,"n":100000},"add tag kernel":{"milliseconds":8.935165999999981,"n":100000},"add tag from four archetypes":{"milliseconds":10.34375,"n":100000},"add value":{"milliseconds":9.755291000000057,"n":100000},"add several":{"milliseconds":13.335959000000003,"n":100000},"remove tag":{"milliseconds":7.1676669999999945,"n":100000},"remove tag kernel":{"milliseconds":5.983541999999943,"n":100000},"remove tag from four archetypes":{"milliseconds":8.007584000000008,"n":100000},"remove several":{"milliseconds":7.879832999999962,"n":100000},"add replace":{"milliseconds":7.710374999999999,"n":100000},"emit main":{"milliseconds":8.875749999999982,"n":1000000},"move workers":{"milliseconds":0.0367920000000197,"n":250000},"follow workers":{"milliseconds":0.6539159999999811,"n":1000000},"emit workers":{"milliseconds":6.519750000000045,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7114579999999933,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.895417000000009,"n":100},"wake":{"milliseconds":7.4103330000000085,"n":10000},"remote run":{"milliseconds":10.659958000000017,"n":1000},"mailbox resource":{"milliseconds":5.150207999999992,"n":1000},"mailbox select 10K":{"milliseconds":784.1340830000004,"n":1000},"log replay":{"milliseconds":0.20441599999999482,"n":10000},"log rebuild 100K":{"milliseconds":0.3324580000000026,"n":100000},"handoff":{"milliseconds":12.095708000000002,"n":1000},"fused tiers":{"milliseconds":7.01483300000001,"n":1000},"despawn objects 10K":{"milliseconds":0.8317079999999919,"n":10000},"tail prompt":{"milliseconds":21.34745799999999,"n":1000},"tail late snapshot 10K":{"milliseconds":0.08479099999999562,"n":10000},"tail overlap":{"milliseconds":255.68129199999998,"n":100},"tail overlap waiting":{"milliseconds":476.07975000000033,"n":100}}} +{"commit":"dcec1e13","dirty":true,"date":"2026-10-18T20:12:14.874Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.37464402260251],[2097152,124.27567406550068],[8388608,76.69584457142857],[33554432,82.85044937700046],[134217728,95.52863207406187]],"threads":[[524288,715.8278826666667],[2097152,247.4059502389093],[8388608,242.92801448373294],[33554432,238.08022706279712],[134217728,206.48881233727812]],"gpu":338.07991937801773},"results":{"move main":{"milliseconds":0.0849999999627471,"n":250000},"follow main":{"milliseconds":3.4900000002235174,"n":1000000},"chain main":{"milliseconds":1.5449999999254942,"n":6000000},"sparse changed main":{"milliseconds":0.2900000000372529,"n":1000000},"emit main":{"milliseconds":9.309999999590218,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.059999999590218,"n":100000},"up depth 10 main":{"milliseconds":2.080000000074506,"n":1000000},"up depth 1000 main":{"milliseconds":2.4300000006332994,"n":1000000},"move workers":{"milliseconds":0.014999999664723873,"n":250000},"follow workers":{"milliseconds":0.5200000004842877,"n":1000000},"chain workers":{"milliseconds":0.23500000033527613,"n":6000000},"sparse changed workers":{"milliseconds":0.054999999701976776,"n":1000000},"emit workers":{"milliseconds":7.884999999776483,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.7199999997392297,"n":100000},"up depth 10 workers":{"milliseconds":0.3349999999627471,"n":1000000},"up depth 1000 workers":{"milliseconds":2.449999999254942,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07499999925494194,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6150000002235174,"n":100},"wake":{"milliseconds":7.919999999925494,"n":10000},"loop tick idle":{"milliseconds":44.38499999977648,"n":10000},"remote run":{"milliseconds":16.274999999441206,"n":1000},"mailbox resource":{"milliseconds":6.355000000447035,"n":1000},"mailbox select 10K":{"milliseconds":743.4799999995157,"n":1000},"log replay":{"milliseconds":0.3049999997019768,"n":10000},"log rebuild 100K":{"milliseconds":0.34999999962747097,"n":100000},"handoff":{"milliseconds":14.700000000186265,"n":1000},"fused tiers":{"milliseconds":9.890000000596046,"n":1000},"despawn objects 10K":{"milliseconds":1.0149999996647239,"n":10000},"tail prompt":{"milliseconds":24.925000000745058,"n":1000},"tail late snapshot 10K":{"milliseconds":0.06499999947845936,"n":10000},"tail overlap":{"milliseconds":258.14499999955297,"n":100},"tail overlap waiting":{"milliseconds":484.1499999994412,"n":100},"transfer":{"milliseconds":13.410000002011657,"n":100},"up depth 10 gpu":{"milliseconds":0.23999999999068677,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.607249999977649,"n":1000000}}} +{"commit":"feecbf80","dirty":true,"date":"2026-10-19T05:53:48.399Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,120.14125743175092],[2097152,94.014694334997],[8388608,62.50560729775302],[33554432,70.57590535033549],[134217728,91.89325632298795]],"threads":[[524288,833.0037424360052],[2097152,245.95605628021292],[8388608,243.33176904846488],[33554432,233.6592489944151],[134217728,210.139254448628]]},"results":{"run idle":{"milliseconds":0.00041599999985919567,"n":1},"run 50 idle":{"milliseconds":0.005374999999958163,"n":50},"loop tick idle":{"milliseconds":18.551416999999987,"n":10000},"sync point one spawn":{"milliseconds":0.0009159999999610591,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.004332999999974163,"n":10000},"main thread 1K":{"milliseconds":0.006582999999977801,"n":1000},"main thread 10K":{"milliseconds":0.062167000000044936,"n":10000},"up depth 10 10K":{"milliseconds":0.017750000000035016,"n":10000},"create":{"milliseconds":58.47912500000075,"n":1000000},"create flat":{"milliseconds":67.25204199999985,"n":1000000},"create flat dynamic":{"milliseconds":91.12562500000013,"n":1000000},"move":{"milliseconds":0.09041600000000471,"n":250000},"churn":{"milliseconds":14.922374999999988,"n":1000000},"retag":{"milliseconds":32.198249999999916,"n":1000000},"sparse changed":{"milliseconds":0.32058399999999665,"n":1000000},"sparse changed clustered":{"milliseconds":0.024042000000008557,"n":1000000},"follow":{"milliseconds":3.8447499999999764,"n":1000000},"emit":{"milliseconds":8.789833999999928,"n":1000000},"reduce":{"milliseconds":0.5047079999999937,"n":1000000},"chain":{"milliseconds":1.5224170000000186,"n":6000000},"up depth 10":{"milliseconds":2.0638329999999314,"n":1000000},"up depth 1000":{"milliseconds":2.377749999999992,"n":1000000},"up one root changed":{"milliseconds":0.020167000000014923,"n":1000000},"up after a spawn":{"milliseconds":2.0823330000000055,"n":1000000},"up filtered after churn":{"milliseconds":0.03991700000005949,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.036541000000056556,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019790999999997894,"n":1000000},"up load":{"milliseconds":211.27754100000004,"n":1000000},"up small tree in a big world":{"milliseconds":0.011083000000013499,"n":1000},"main thread":{"milliseconds":0.3353749999999991,"n":50000},"select":{"milliseconds":8.219959000000017,"n":1000000},"spawn bulk":{"milliseconds":2.54525000000001,"n":100000},"spawn bulk kernel":{"milliseconds":1.0324170000000095,"n":100000},"spawn singles":{"milliseconds":0.001165999999983569,"n":4},"spawn sixteen archetypes":{"milliseconds":3.996208000000024,"n":100000},"despawn bulk":{"milliseconds":4.4194160000000124,"n":100000},"despawn bulk kernel":{"milliseconds":4.4793750000000045,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":9.50470800000005,"n":100000},"add tag kernel":{"milliseconds":8.382207999999991,"n":100000},"add tag from four archetypes":{"milliseconds":9.72787500000004,"n":100000},"add value":{"milliseconds":9.649415999999974,"n":100000},"add several":{"milliseconds":13.246208000000024,"n":100000},"remove tag":{"milliseconds":7.03491699999995,"n":100000},"remove tag kernel":{"milliseconds":5.945124999999962,"n":100000},"remove tag from four archetypes":{"milliseconds":7.409832999999935,"n":100000},"remove several":{"milliseconds":7.938791999999921,"n":100000},"add replace":{"milliseconds":7.6889999999999645,"n":100000},"emit main":{"milliseconds":8.718082999999979,"n":1000000},"move workers":{"milliseconds":0.01929100000006656,"n":250000},"follow workers":{"milliseconds":0.5819589999999835,"n":1000000},"emit workers":{"milliseconds":6.180292000000009,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6814170000000104,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.7563339999999243,"n":100},"wake":{"milliseconds":7.168082999999996,"n":10000},"remote run":{"milliseconds":9.517791000000003,"n":1000},"mailbox resource":{"milliseconds":5.230333999999999,"n":1000},"mailbox select 10K":{"milliseconds":752.4435830000002,"n":1000},"log replay":{"milliseconds":0.20629100000000733,"n":10000},"log rebuild 100K":{"milliseconds":0.30529099999999687,"n":100000},"handoff":{"milliseconds":10.090666999999996,"n":1000},"fused tiers":{"milliseconds":6.755499999999984,"n":1000},"despawn objects 10K":{"milliseconds":0.8000830000000292,"n":10000},"tail prompt":{"milliseconds":18.705292000000014,"n":1000},"tail late snapshot 10K":{"milliseconds":0.06970799999999144,"n":10000},"tail overlap":{"milliseconds":243.18299999999988,"n":100},"tail overlap waiting":{"milliseconds":459.776333,"n":100},"read observe":{"milliseconds":0.0005410000000054538,"n":1},"read run":{"milliseconds":0.00970799999998917,"n":1},"read run changed":{"milliseconds":0.012042000000008102,"n":1},"observe 1K entities":{"milliseconds":0.025916999999935797,"n":1000},"observe 1K entities one changed":{"milliseconds":0.026041999999961263,"n":1000},"select page 100K":{"milliseconds":1.1233330000000024,"n":100000},"loop tick add remove":{"milliseconds":236.210417,"n":10000},"loop tick add remove again":{"milliseconds":214.3231249999999,"n":10000},"loop tick idle after add remove":{"milliseconds":19.42254200000002,"n":10000}}} +{"commit":"feecbf80","dirty":true,"date":"2026-10-19T05:55:00.768Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.37464372096764],[2097152,123.70297501327273],[8388608,65.15423681779622],[33554432,65.63214077390738],[134217728,97.6128930909091]],"threads":[[524288,865.9208295525494],[2097152,250.28946995071752],[8388608,242.37964466774352],[33554432,232.9157969932383],[134217728,208.0895009194159]],"gpu":357.67549123324847},"results":{"move main":{"milliseconds":0.08499999344348907,"n":250000},"follow main":{"milliseconds":3.435000002384186,"n":1000000},"chain main":{"milliseconds":1.530000001192093,"n":6000000},"sparse changed main":{"milliseconds":0.28999999910593033,"n":1000000},"emit main":{"milliseconds":9.274999998509884,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0249999985098839,"n":100000},"up depth 10 main":{"milliseconds":2.0399999991059303,"n":1000000},"up depth 1000 main":{"milliseconds":2.339999996125698,"n":1000000},"move workers":{"milliseconds":0.01499999314546585,"n":250000},"follow workers":{"milliseconds":0.5200000032782555,"n":1000000},"chain workers":{"milliseconds":0.23499999940395355,"n":6000000},"sparse changed workers":{"milliseconds":0.054999999701976776,"n":1000000},"emit workers":{"milliseconds":7.6450000032782555,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6599999964237213,"n":100000},"up depth 10 workers":{"milliseconds":0.32999999821186066,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3849999979138374,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07499999552965164,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.5949999988079071,"n":100},"wake":{"milliseconds":7.304999999701977,"n":10000},"loop tick idle":{"milliseconds":43.625,"n":10000},"remote run":{"milliseconds":14.440000005066395,"n":1000},"mailbox resource":{"milliseconds":6.024999998509884,"n":1000},"mailbox select 10K":{"milliseconds":724.8399999961257,"n":1000},"log replay":{"milliseconds":0.3449999988079071,"n":10000},"log rebuild 100K":{"milliseconds":0.35499999672174454,"n":100000},"handoff":{"milliseconds":13.369999997317791,"n":1000},"fused tiers":{"milliseconds":9.385000005364418,"n":1000},"despawn objects 10K":{"milliseconds":1.0499999970197678,"n":10000},"tail prompt":{"milliseconds":23.484999999403954,"n":1000},"tail late snapshot 10K":{"milliseconds":0.04999999701976776,"n":10000},"tail overlap":{"milliseconds":247.25,"n":100},"tail overlap waiting":{"milliseconds":462.8500000014901,"n":100},"transfer":{"milliseconds":13.78999999910593,"n":100},"up depth 10 gpu":{"milliseconds":0.2392500001937151,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.303749999776483,"n":1000000}}} +{"commit":"45cb8487","dirty":true,"date":"2026-10-19T09:57:00.656Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,120.0293040293045],[2097152,117.10147395752512],[8388608,73.74264140701231],[33554432,71.47951466388045],[134217728,96.73642589541384]],"threads":[[524288,882.5874941639786],[2097152,245.1185535897738],[8388608,238.99870277527845],[33554432,237.62794362127434],[134217728,207.6769641700107]]},"results":{"run idle":{"milliseconds":0.0004159999999728825,"n":1},"run 50 idle":{"milliseconds":0.005666000000019267,"n":50},"loop tick idle":{"milliseconds":18.806583999999987,"n":10000},"sync point one spawn":{"milliseconds":0.0009159999999610591,"n":1},"move 1K":{"milliseconds":0.0007079999999177744,"n":1000},"move 10K":{"milliseconds":0.00437499999998181,"n":10000},"main thread 1K":{"milliseconds":0.006666999999993095,"n":1000},"main thread 10K":{"milliseconds":0.06254100000001017,"n":10000},"up depth 10 10K":{"milliseconds":0.01791600000001381,"n":10000},"create":{"milliseconds":58.052709000000505,"n":1000000},"create flat":{"milliseconds":65.14820799999961,"n":1000000},"create flat dynamic":{"milliseconds":91.75624999999945,"n":1000000},"move":{"milliseconds":0.09024999999996908,"n":250000},"churn":{"milliseconds":15.080875000000106,"n":1000000},"retag":{"milliseconds":32.19129100000009,"n":1000000},"sparse changed":{"milliseconds":0.32262499999998795,"n":1000000},"sparse changed clustered":{"milliseconds":0.022958999999985963,"n":1000000},"follow":{"milliseconds":3.8897499999999923,"n":1000000},"emit":{"milliseconds":8.799207999999908,"n":1000000},"reduce":{"milliseconds":0.503832999999986,"n":1000000},"chain":{"milliseconds":1.5631250000000136,"n":6000000},"up depth 10":{"milliseconds":2.0534170000000813,"n":1000000},"up depth 1000":{"milliseconds":2.4068329999998923,"n":1000000},"up one root changed":{"milliseconds":0.021042000000079497,"n":1000000},"up after a spawn":{"milliseconds":2.0919580000000337,"n":1000000},"up filtered after churn":{"milliseconds":0.040792000000010376,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04337499999996908,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019749999999987722,"n":1000000},"up load":{"milliseconds":208.38733299999998,"n":1000000},"up small tree in a big world":{"milliseconds":0.010916000000008808,"n":1000},"main thread":{"milliseconds":0.32849999999999113,"n":50000},"select":{"milliseconds":8.159249999999986,"n":1000000},"spawn bulk":{"milliseconds":2.5588750000000005,"n":100000},"spawn bulk kernel":{"milliseconds":1.0358329999999967,"n":100000},"spawn singles":{"milliseconds":0.001165999999898304,"n":4},"spawn sixteen archetypes":{"milliseconds":4.002292000000011,"n":100000},"despawn bulk":{"milliseconds":4.429500000000019,"n":100000},"despawn bulk kernel":{"milliseconds":4.481875000000002,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":9.621792000000028,"n":100000},"add tag kernel":{"milliseconds":8.471125000000029,"n":100000},"add tag from four archetypes":{"milliseconds":9.859874999999988,"n":100000},"add value":{"milliseconds":9.601167000000032,"n":100000},"add several":{"milliseconds":13.094625000000065,"n":100000},"remove tag":{"milliseconds":7.060957999999971,"n":100000},"remove tag kernel":{"milliseconds":6.033791000000008,"n":100000},"remove tag from four archetypes":{"milliseconds":7.794708000000014,"n":100000},"remove several":{"milliseconds":7.846333999999956,"n":100000},"add replace":{"milliseconds":7.832915999999955,"n":100000},"emit main":{"milliseconds":8.862875000000031,"n":1000000},"move workers":{"milliseconds":0.01920899999993253,"n":250000},"follow workers":{"milliseconds":0.5878329999999323,"n":1000000},"emit workers":{"milliseconds":6.255167000000029,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6757500000000505,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.8231660000000147,"n":100},"wake":{"milliseconds":7.06966700000001,"n":10000},"remote run":{"milliseconds":9.621375,"n":1000},"mailbox resource":{"milliseconds":5.224208000000004,"n":1000},"mailbox select 10K":{"milliseconds":754.2247500000003,"n":1000},"log replay":{"milliseconds":0.20462499999999295,"n":10000},"log rebuild 100K":{"milliseconds":0.3117500000000035,"n":100000},"handoff":{"milliseconds":10.789625000000001,"n":1000},"fused tiers":{"milliseconds":6.219417000000021,"n":1000},"despawn objects 10K":{"milliseconds":0.8174579999999878,"n":10000},"tail prompt":{"milliseconds":19.691542,"n":1000},"tail late snapshot 10K":{"milliseconds":0.07045799999997371,"n":10000},"tail overlap":{"milliseconds":254.40195799999992,"n":100},"tail overlap waiting":{"milliseconds":459.56137499999977,"n":100},"read observe":{"milliseconds":0.0005409999999983484,"n":1},"read run":{"milliseconds":0.011625000000009322,"n":1},"read run changed":{"milliseconds":0.012707999999975073,"n":1},"observe 1K entities":{"milliseconds":0.02400000000000091,"n":1000},"observe 1K entities one changed":{"milliseconds":0.02491599999996197,"n":1000},"select page 100K":{"milliseconds":1.1474590000000262,"n":100000},"loop tick add remove":{"milliseconds":268.300708,"n":10000},"loop tick add remove again":{"milliseconds":203.3522499999999,"n":10000},"loop tick idle after add remove":{"milliseconds":19.856333000000177,"n":10000},"index idle":{"milliseconds":0.009457999999995081,"n":1000},"index refresh 1M 1%":{"milliseconds":1.6907500000000084,"n":10000},"index rebuild 1M main":{"milliseconds":27.453292000000033,"n":1000000},"index rebuild 1M workers":{"milliseconds":6.523208000000011,"n":1000000},"rekey 1M":{"milliseconds":14.346250000000055,"n":1000000},"rekey 1M unindexed":{"milliseconds":4.842624999999998,"n":1000000},"order run 100K":{"milliseconds":3.1237499999999727,"n":100000},"order run 100K storage order":{"milliseconds":2.8409580000000005,"n":100000},"order filtered 100K 1%":{"milliseconds":0.17908300000000565,"n":1000},"unique lookup 1M":{"milliseconds":45.188458999999966,"n":1000000},"children lookup":{"milliseconds":3.265083000000004,"n":100000},"sorted range 1M":{"milliseconds":8.403291999999965,"n":10000},"sorted page 100K":{"milliseconds":0.023416999999994914,"n":100000},"grid neighbors 100K main":{"milliseconds":1109.8566250000003,"n":100000},"grid neighbors 100K workers":{"milliseconds":17.845374999999876,"n":100000}}} +{"commit":"cfb4b7ec","dirty":true,"date":"2026-10-19T10:11:22.540Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,118.77675016054508],[2097152,116.7110679773157],[8388608,63.91320376417234],[33554432,65.47206229625223],[134217728,93.53151761463657]],"threads":[[524288,740.511601236623],[2097152,248.55134855967077],[8388608,246.83720125776193],[33554432,231.90968144834375],[134217728,208.08950031849048]],"gpu":350.2093359834457},"results":{"move main":{"milliseconds":0.08499999344348907,"n":250000},"follow main":{"milliseconds":3.440000005066395,"n":1000000},"chain main":{"milliseconds":1.5450000017881393,"n":6000000},"sparse changed main":{"milliseconds":0.2850000038743019,"n":1000000},"emit main":{"milliseconds":9.074999995529652,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0250000059604645,"n":100000},"up depth 10 main":{"milliseconds":2.0500000044703484,"n":1000000},"up depth 1000 main":{"milliseconds":2.369999997317791,"n":1000000},"move workers":{"milliseconds":0.01499999314546585,"n":250000},"follow workers":{"milliseconds":0.5199999958276749,"n":1000000},"chain workers":{"milliseconds":0.23499999940395355,"n":6000000},"sparse changed workers":{"milliseconds":0.054999999701976776,"n":1000000},"emit workers":{"milliseconds":7.614999994635582,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6899999976158142,"n":100000},"up depth 10 workers":{"milliseconds":0.3399999961256981,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3650000020861626,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07500000298023224,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6450000032782555,"n":100},"index rebuild 1M workers":{"milliseconds":7.244999997317791,"n":1000000},"grid neighbors 100K workers":{"milliseconds":17.41499999910593,"n":100000},"wake":{"milliseconds":7.410000003874302,"n":10000},"loop tick idle":{"milliseconds":44.439999997615814,"n":10000},"remote run":{"milliseconds":14.95499999821186,"n":1000},"mailbox resource":{"milliseconds":6.130000002682209,"n":1000},"mailbox select 10K":{"milliseconds":727.8400000035763,"n":1000},"log replay":{"milliseconds":0.3399999961256981,"n":10000},"log rebuild 100K":{"milliseconds":0.36499999463558197,"n":100000},"handoff":{"milliseconds":13.695000000298023,"n":1000},"fused tiers":{"milliseconds":9.090000003576279,"n":1000},"despawn objects 10K":{"milliseconds":1.0799999982118607,"n":10000},"tail prompt":{"milliseconds":23.729999996721745,"n":1000},"tail late snapshot 10K":{"milliseconds":0.04999999701976776,"n":10000},"tail overlap":{"milliseconds":247.875,"n":100},"tail overlap waiting":{"milliseconds":464.7850000038743,"n":100},"transfer":{"milliseconds":14.429999992251396,"n":100},"up depth 10 gpu":{"milliseconds":0.23399999998509885,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.272499999776483,"n":1000000}}} +{"commit":"c20ed043","dirty":true,"date":"2026-10-19T20:22:17.953Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,122.05317614621177],[2097152,90.91802066045723],[8388608,63.83216735325537],[33554432,80.48435829398092],[134217728,92.32251106072812]],"threads":[[524288,832.089670307874],[2097152,247.32525609156198],[8388608,246.45477110231704],[33554432,235.40940039919258],[134217728,207.9921153196485]]},"results":{"run idle":{"milliseconds":0.0004159999999728825,"n":1},"run 50 idle":{"milliseconds":0.005583000000001448,"n":50},"loop tick idle":{"milliseconds":18.85262499999999,"n":10000},"sync point one spawn":{"milliseconds":0.0009159999999610591,"n":1},"move 1K":{"milliseconds":0.0007080000000314612,"n":1000},"move 10K":{"milliseconds":0.004332999999974163,"n":10000},"main thread 1K":{"milliseconds":0.006624999999985448,"n":1000},"main thread 10K":{"milliseconds":0.06258300000001782,"n":10000},"up depth 10 10K":{"milliseconds":0.017790999999988344,"n":10000},"create":{"milliseconds":58.18858300000011,"n":1000000},"create flat":{"milliseconds":64.50675000000047,"n":1000000},"create flat dynamic":{"milliseconds":93.9834579999997,"n":1000000},"move":{"milliseconds":0.09029099999997925,"n":250000},"churn":{"milliseconds":15.50370799999996,"n":1000000},"retag":{"milliseconds":32.46362500000009,"n":1000000},"sparse changed":{"milliseconds":0.32295799999999986,"n":1000000},"sparse changed clustered":{"milliseconds":0.022542000000015605,"n":1000000},"follow":{"milliseconds":3.834875000000011,"n":1000000},"emit":{"milliseconds":8.687666000000036,"n":1000000},"reduce":{"milliseconds":0.5039590000000089,"n":1000000},"chain":{"milliseconds":1.5425410000000284,"n":6000000},"up depth 10":{"milliseconds":2.040207999999893,"n":1000000},"up depth 1000":{"milliseconds":2.3893749999999727,"n":1000000},"up one root changed":{"milliseconds":0.020541999999977634,"n":1000000},"up after a spawn":{"milliseconds":2.060834,"n":1000000},"up filtered after churn":{"milliseconds":0.03920899999991434,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.03708399999993617,"n":1000000},"up filtered after a reparent":{"milliseconds":0.02020799999996825,"n":1000000},"up load":{"milliseconds":210.3582080000001,"n":1000000},"up small tree in a big world":{"milliseconds":0.011374999999986812,"n":1000},"main thread":{"milliseconds":0.32879199999999287,"n":50000},"select":{"milliseconds":8.159999999999968,"n":1000000},"spawn bulk":{"milliseconds":2.556666000000007,"n":100000},"spawn bulk kernel":{"milliseconds":1.045375000000007,"n":100000},"spawn singles":{"milliseconds":0.0011660000000119908,"n":4},"spawn sixteen archetypes":{"milliseconds":4.078042000000011,"n":100000},"despawn bulk":{"milliseconds":4.540750000000003,"n":100000},"despawn bulk kernel":{"milliseconds":4.551124999999956,"n":100000},"despawn one":{"milliseconds":0.0015829999999965594,"n":1},"add tag":{"milliseconds":9.646082999999976,"n":100000},"add tag kernel":{"milliseconds":8.521874999999966,"n":100000},"add tag from four archetypes":{"milliseconds":9.933457999999973,"n":100000},"add value":{"milliseconds":9.944082999999978,"n":100000},"add several":{"milliseconds":13.003584000000046,"n":100000},"remove tag":{"milliseconds":7.124750000000063,"n":100000},"remove tag kernel":{"milliseconds":5.928125000000023,"n":100000},"remove tag from four archetypes":{"milliseconds":7.7461660000000165,"n":100000},"remove several":{"milliseconds":7.895415999999955,"n":100000},"add replace":{"milliseconds":7.781416999999919,"n":100000},"emit main":{"milliseconds":8.720125000000053,"n":1000000},"move workers":{"milliseconds":0.019457999999985987,"n":250000},"follow workers":{"milliseconds":0.5850829999999405,"n":1000000},"emit workers":{"milliseconds":6.131583999999975,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6925840000000107,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.82650000000001,"n":100},"wake":{"milliseconds":7.037708999999992,"n":10000},"remote run":{"milliseconds":9.929541,"n":1000},"mailbox resource":{"milliseconds":5.161417,"n":1000},"mailbox select 10K":{"milliseconds":749.652333,"n":1000},"log replay":{"milliseconds":0.20416700000001242,"n":10000},"log rebuild 100K":{"milliseconds":0.30254200000000253,"n":100000},"handoff":{"milliseconds":10.488250000000022,"n":1000},"fused tiers":{"milliseconds":6.784415999999993,"n":1000},"despawn objects 10K":{"milliseconds":0.8395000000000152,"n":10000},"tail prompt":{"milliseconds":19.615042000000017,"n":1000},"tail late snapshot 10K":{"milliseconds":0.06987500000002456,"n":10000},"tail overlap":{"milliseconds":244.44274999999993,"n":100},"tail overlap waiting":{"milliseconds":459.22345799999994,"n":100},"read observe":{"milliseconds":0.0005419999999958236,"n":1},"read run":{"milliseconds":0.010250000000041837,"n":1},"read run changed":{"milliseconds":0.011916999999982636,"n":1},"observe 1K entities":{"milliseconds":0.02400000000000091,"n":1000},"observe 1K entities one changed":{"milliseconds":0.025083999999992557,"n":1000},"select page 100K":{"milliseconds":1.1226669999998649,"n":100000},"loop tick add remove":{"milliseconds":271.12091699999996,"n":10000},"loop tick add remove again":{"milliseconds":207.398959,"n":10000},"loop tick idle after add remove":{"milliseconds":19.896166999999878,"n":10000},"index idle":{"milliseconds":0.00937500000009095,"n":1000},"index refresh 1M 1%":{"milliseconds":1.4063749999999686,"n":10000},"index rebuild 1M main":{"milliseconds":26.66095899999999,"n":1000000},"index rebuild 1M workers":{"milliseconds":6.6056670000000395,"n":1000000},"rekey 1M":{"milliseconds":12.325207999999975,"n":1000000},"rekey 1M unindexed":{"milliseconds":4.765916000000061,"n":1000000},"order run 100K":{"milliseconds":3.110458000000108,"n":100000},"order run 100K storage order":{"milliseconds":2.8359169999998812,"n":100000},"order filtered 100K 1%":{"milliseconds":0.17391700000000299,"n":1000},"unique lookup 1M":{"milliseconds":44.012459000000035,"n":1000000},"children lookup":{"milliseconds":1.3065420000000358,"n":100000},"sorted range 1M":{"milliseconds":5.631875000000036,"n":10000},"sorted page 100K":{"milliseconds":0.0049579999999878055,"n":100000},"grid neighbors 100K main":{"milliseconds":365.62424999999985,"n":100000},"grid neighbors 100K workers":{"milliseconds":17.859915999999885,"n":100000}}} +{"commit":"c20ed043","dirty":true,"date":"2026-10-19T20:23:46.199Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,120.91687213700186],[2097152,109.12010501685505],[8388608,71.77418591323745],[33554432,65.63214077390738],[134217728,97.25922352446965]],"threads":[[524288,795.3643175857339],[2097152,246.27106142580593],[8388608,245.14653375867894],[33554432,232.41165147579693],[134217728,208.8991869188027]],"gpu":348.84399772516264},"results":{"move main":{"milliseconds":0.08499999344348907,"n":250000},"follow main":{"milliseconds":3.480000004172325,"n":1000000},"chain main":{"milliseconds":1.5450000017881393,"n":6000000},"sparse changed main":{"milliseconds":0.29500000178813934,"n":1000000},"emit main":{"milliseconds":9.109999999403954,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.030000001192093,"n":100000},"up depth 10 main":{"milliseconds":2.030000001192093,"n":1000000},"up depth 1000 main":{"milliseconds":2.364999994635582,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5199999958276749,"n":1000000},"chain workers":{"milliseconds":0.23499999940395355,"n":6000000},"sparse changed workers":{"milliseconds":0.05499999225139618,"n":1000000},"emit workers":{"milliseconds":7.679999992251396,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6649999916553497,"n":100000},"up depth 10 workers":{"milliseconds":0.32500000298023224,"n":1000000},"up depth 1000 workers":{"milliseconds":2.3700000047683716,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07000000774860382,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6099999994039536,"n":100},"index rebuild 1M workers":{"milliseconds":6.415000006556511,"n":1000000},"grid neighbors 100K workers":{"milliseconds":17.41499999165535,"n":100000},"wake":{"milliseconds":7.534999996423721,"n":10000},"loop tick idle":{"milliseconds":43.435000002384186,"n":10000},"remote run":{"milliseconds":15.08500000834465,"n":1000},"mailbox resource":{"milliseconds":6.46000000834465,"n":1000},"mailbox select 10K":{"milliseconds":727.684999987483,"n":1000},"log replay":{"milliseconds":0.3049999922513962,"n":10000},"log rebuild 100K":{"milliseconds":0.35999999940395355,"n":100000},"handoff":{"milliseconds":13.679999992251396,"n":1000},"fused tiers":{"milliseconds":9.924999997019768,"n":1000},"despawn objects 10K":{"milliseconds":1.0499999970197678,"n":10000},"tail prompt":{"milliseconds":23.21000000834465,"n":1000},"tail late snapshot 10K":{"milliseconds":0.04500000178813934,"n":10000},"tail overlap":{"milliseconds":259.05000001192093,"n":100},"tail overlap waiting":{"milliseconds":471.17499999701977,"n":100},"transfer":{"milliseconds":11.504999995231628,"n":100},"up depth 10 gpu":{"milliseconds":0.23799999952316284,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.277250000089407,"n":1000000}}} +{"commit":"e3c30aa1","dirty":true,"date":"2026-10-20T08:11:25.570Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"node 24.15.0","bandwidth":{"thread":[[524288,124.4004200508468],[2097152,123.59384801253765],[8388608,85.26275373753361],[33554432,73.76286447891304],[134217728,96.60003296346535]],"threads":[[524288,669.72825448308],[2097152,245.2912457943203],[8388608,238.30913063279334],[33554432,231.80124028370392],[134217728,202.67248933354222]]},"results":{"run idle":{"milliseconds":0.0003329999999550637,"n":1},"run 50 idle":{"milliseconds":0.0035409999999274078,"n":50},"run observer":{"milliseconds":0.0001659999999219508,"n":1},"observer idle 100":{"milliseconds":84.45520899999997,"n":1000000},"loop tick idle":{"milliseconds":19.478707999999983,"n":10000},"sync point one spawn":{"milliseconds":0.0008329999999432403,"n":1},"move 1K":{"milliseconds":0.0006659999999101274,"n":1000},"move 10K":{"milliseconds":0.004207999999948697,"n":10000},"main thread 1K":{"milliseconds":0.006583999999975276,"n":1000},"main thread 10K":{"milliseconds":0.06262500000002547,"n":10000},"up depth 10 10K":{"milliseconds":0.01766600000001972,"n":10000},"create":{"milliseconds":58.372832999999446,"n":1000000},"create flat":{"milliseconds":65.95400000000018,"n":1000000},"create flat dynamic":{"milliseconds":92.28833300000042,"n":1000000},"move":{"milliseconds":0.08979099999999107,"n":250000},"churn":{"milliseconds":15.074124999999981,"n":1000000},"retag":{"milliseconds":32.307375000000036,"n":1000000},"sparse changed":{"milliseconds":0.31816700000001674,"n":1000000},"sparse changed clustered":{"milliseconds":0.022249999999985448,"n":1000000},"follow":{"milliseconds":3.8198750000000246,"n":1000000},"emit":{"milliseconds":8.766458000000057,"n":1000000},"reduce":{"milliseconds":0.5035830000000487,"n":1000000},"chain":{"milliseconds":1.5564999999999714,"n":6000000},"up depth 10":{"milliseconds":2.0208749999999327,"n":1000000},"up depth 1000":{"milliseconds":2.3876659999999674,"n":1000000},"up one root changed":{"milliseconds":0.024415999999973792,"n":1000000},"up after a spawn":{"milliseconds":2.090250000000026,"n":1000000},"up filtered after churn":{"milliseconds":0.05033300000013696,"n":1000000},"up filtered after tagged churn":{"milliseconds":0.04133300000012241,"n":1000000},"up filtered after a reparent":{"milliseconds":0.019624999999962256,"n":1000000},"up load":{"milliseconds":214.85908300000006,"n":1000000},"up small tree in a big world":{"milliseconds":0.0105829999999969,"n":1000},"main thread":{"milliseconds":0.32791699999999935,"n":50000},"spawn bulk":{"milliseconds":2.516999999999996,"n":100000},"spawn bulk kernel":{"milliseconds":1.0494999999999948,"n":100000},"spawn singles":{"milliseconds":0.001082999999994172,"n":4},"spawn sixteen archetypes":{"milliseconds":4.00245799999999,"n":100000},"despawn bulk":{"milliseconds":4.405167000000006,"n":100000},"despawn bulk kernel":{"milliseconds":4.399541999999997,"n":100000},"despawn one":{"milliseconds":0.0014999999999929514,"n":1},"add tag":{"milliseconds":9.541874999999948,"n":100000},"add tag kernel":{"milliseconds":8.294957999999951,"n":100000},"add tag from four archetypes":{"milliseconds":9.589749999999981,"n":100000},"add value":{"milliseconds":9.548625000000015,"n":100000},"add several":{"milliseconds":12.926709000000074,"n":100000},"remove tag":{"milliseconds":7.013000000000034,"n":100000},"remove tag kernel":{"milliseconds":5.89933300000007,"n":100000},"remove tag from four archetypes":{"milliseconds":7.777625000000057,"n":100000},"remove several":{"milliseconds":7.789583999999991,"n":100000},"add replace":{"milliseconds":7.445416000000023,"n":100000},"emit main":{"milliseconds":8.677625000000035,"n":1000000},"move workers":{"milliseconds":0.01987500000007003,"n":250000},"follow workers":{"milliseconds":0.5773749999999609,"n":1000000},"emit workers":{"milliseconds":6.466875000000073,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.6973330000000146,"n":100000},"sync point grow 100 archetypes workers":{"milliseconds":0.778082999999981,"n":100},"wake":{"milliseconds":7.811417000000006,"n":10000},"remote run":{"milliseconds":10.088707999999997,"n":1000},"joined observer resource":{"milliseconds":4.5975839999999835,"n":1000},"joined observer 10K":{"milliseconds":434.4675410000002,"n":1000},"joined resource factory":{"milliseconds":4.826667000000015,"n":1000},"joined select 10K factory":{"milliseconds":488.40545800000007,"n":1000},"joined resource factory cadence":{"milliseconds":5.515083799999866,"n":100},"log replay":{"milliseconds":0.2033749999999941,"n":10000},"log rebuild 100K":{"milliseconds":0.3317920000000072,"n":100000},"handoff":{"milliseconds":9.97912500000001,"n":1000},"fused tiers":{"milliseconds":6.5660000000000025,"n":1000},"despawn objects 10K":{"milliseconds":0.8028750000000286,"n":10000},"tail prompt":{"milliseconds":20.324082999999973,"n":1000},"tail late snapshot 10K":{"milliseconds":0.08241600000002336,"n":10000},"tail overlap":{"milliseconds":244.02145799999994,"n":100},"tail overlap waiting":{"milliseconds":468.67599999999993,"n":100},"read run":{"milliseconds":0.011333000000007587,"n":1},"read run observer":{"milliseconds":0.007999999999981355,"n":1},"read run changed":{"milliseconds":0.01216600000000767,"n":1},"lookup 1K entities":{"milliseconds":0.021208000000001448,"n":1000},"lookup 1K entities changed":{"milliseconds":0.02341599999999744,"n":1000},"lookup 1K entities one changed":{"milliseconds":0.021374999999920874,"n":1000},"lookup 1K entities changed one changed":{"milliseconds":0.023500000000012733,"n":1000},"read observe factory warm":{"milliseconds":0.00012499999991177901,"n":1},"read observe factory cold":{"milliseconds":0.0008329999996021797,"n":1},"observe 1K entities factory":{"milliseconds":0.00045799999998052954,"n":1000},"observe 1K entities one changed factory":{"milliseconds":0.03012499999999818,"n":1000},"observe one entity among 100K moving factory":{"milliseconds":0.044499999999970896,"n":1},"observe subscribe churn 1K factory":{"milliseconds":0.18183399999998073,"n":1000},"select page 100K factory":{"milliseconds":0.016208000000005995,"n":100000},"select page 100K churn factory":{"milliseconds":0.0030409999999960746,"n":1},"select factory":{"milliseconds":7.622458999999992,"n":1000000},"count 1M changed factory":{"milliseconds":0.042207999999845924,"n":1000000},"loop tick add remove":{"milliseconds":243.68349999999987,"n":10000},"loop tick add remove again":{"milliseconds":206.57958400000007,"n":10000},"loop tick idle after add remove":{"milliseconds":17.777333999999883,"n":10000},"index idle":{"milliseconds":0.00924999999983811,"n":1000},"keyless index idle":{"milliseconds":0.007041999999955806,"n":1000},"keyless refresh 1M 1%":{"milliseconds":1.8649159999999938,"n":10000},"keyless spawn 1M 1%":{"milliseconds":2.0682500000000346,"n":20000},"keyless write 1M":{"milliseconds":0.0030000000000143245,"n":1000000},"keyless length":{"milliseconds":0.469333000000006,"n":100000},"index refresh 1M 1%":{"milliseconds":1.3815000000000168,"n":10000},"index rebuild 1M main":{"milliseconds":26.942708999999923,"n":1000000},"index rebuild 1M workers":{"milliseconds":6.276792,"n":1000000},"rekey 1M":{"milliseconds":12.523124999999936,"n":1000000},"rekey 1M unindexed":{"milliseconds":4.776375000000144,"n":1000000},"order run 100K":{"milliseconds":3.1259999999999764,"n":100000},"order run 100K storage order":{"milliseconds":3.020542000000205,"n":100000},"order filtered 100K 1%":{"milliseconds":0.1899580000000043,"n":1000},"unique lookup 1M":{"milliseconds":53.153166000000056,"n":1000000},"children lookup":{"milliseconds":1.3048750000000382,"n":100000},"sorted range 1M":{"milliseconds":5.619874999999865,"n":10000},"sorted page 100K":{"milliseconds":0.004500000000007276,"n":100000},"grid neighbors 100K main":{"milliseconds":377.2606249999999,"n":100000},"grid neighbors 100K workers":{"milliseconds":17.98050000000012,"n":100000}}} +{"commit":"e3c30aa1","dirty":true,"date":"2026-10-20T08:13:00.910Z","machine":{"key":"apple-m1-max-10-cores-32gb-darwin-arm64","label":"Apple M1 Max, 10 cores, 32 GB, darwin arm64","cpu":"Apple M1 Max","cores":10,"memory":32,"platform":"darwin","arch":"arm64"},"environment":"Chrome 156.0.0.0","bandwidth":{"thread":[[524288,119.30464711111111],[2097152,112.31609104882618],[8388608,70.27106191168005],[33554432,89.47848533333334],[134217728,95.86980530612244]],"threads":[[524288,745.6540456790124],[2097152,245.14653542670086],[8388608,239.67451326530613],[33554432,239.1407175321551],[134217728,212.62214286442506]],"gpu":353.43707181320997},"results":{"move main":{"milliseconds":0.08499997854232788,"n":250000},"follow main":{"milliseconds":3.419999986886978,"n":1000000},"chain main":{"milliseconds":1.5600000023841858,"n":6000000},"sparse changed main":{"milliseconds":0.30000001192092896,"n":1000000},"emit main":{"milliseconds":9.179999977350235,"n":1000000},"spawn bulk kernel main":{"milliseconds":1.0349999964237213,"n":100000},"up depth 10 main":{"milliseconds":2.030000001192093,"n":1000000},"up depth 1000 main":{"milliseconds":2.344999998807907,"n":1000000},"move workers":{"milliseconds":0.014999985694885254,"n":250000},"follow workers":{"milliseconds":0.5250000059604645,"n":1000000},"chain workers":{"milliseconds":0.23999997973442078,"n":6000000},"sparse changed workers":{"milliseconds":0.06499999761581421,"n":1000000},"emit workers":{"milliseconds":7.7249999940395355,"n":1000000},"spawn bulk kernel workers":{"milliseconds":0.699999988079071,"n":100000},"up depth 10 workers":{"milliseconds":0.3449999988079071,"n":1000000},"up depth 1000 workers":{"milliseconds":2.375,"n":1000000},"sync point grow 100 archetypes main":{"milliseconds":0.07499998807907104,"n":100},"sync point grow 100 archetypes workers":{"milliseconds":0.6049999892711639,"n":100},"index rebuild 1M workers":{"milliseconds":6.9200000166893005,"n":1000000},"grid neighbors 100K workers":{"milliseconds":17.609999984502792,"n":100000},"wake":{"milliseconds":8.475000023841858,"n":10000},"loop tick idle":{"milliseconds":42.84500002861023,"n":10000},"remote run":{"milliseconds":14.560000002384186,"n":1000},"joined observer resource":{"milliseconds":6.439999997615814,"n":1000},"joined observer 10K":{"milliseconds":357.1850000023842,"n":1000},"joined resource factory":{"milliseconds":6.83500000834465,"n":1000},"joined select 10K factory":{"milliseconds":360.71000000834465,"n":1000},"joined resource factory cadence":{"milliseconds":6.603999954462052,"n":100},"log replay":{"milliseconds":0.3199999928474426,"n":10000},"log rebuild 100K":{"milliseconds":0.2900000214576721,"n":100000},"handoff":{"milliseconds":14.08500000834465,"n":1000},"fused tiers":{"milliseconds":9.870000004768372,"n":1000},"despawn objects 10K":{"milliseconds":1.0450000166893005,"n":10000},"tail prompt":{"milliseconds":22.82499998807907,"n":1000},"tail late snapshot 10K":{"milliseconds":0.04999998211860657,"n":10000},"tail overlap":{"milliseconds":260.02499997615814,"n":100},"tail overlap waiting":{"milliseconds":474.25,"n":100},"transfer":{"milliseconds":20.004999965429306,"n":100},"observe gpu entity factory":{"milliseconds":0.3925000011920929,"n":1},"up depth 10 gpu":{"milliseconds":0.23399999886751174,"n":1000000},"up depth 1000 gpu":{"milliseconds":4.354500000178814,"n":1000000}}} diff --git a/packages/ecs/scripts/perf.mjs b/packages/ecs/scripts/perf.mjs new file mode 100644 index 000000000..f66ba27b9 --- /dev/null +++ b/packages/ecs/scripts/perf.mjs @@ -0,0 +1,285 @@ +// Runs the benchmarks, compares them with what this machine recorded last, and records them. +// +// pnpm perf [filter] node benchmarks whose names match the regular expression filter, each in a process +// of its own, compared with the last record +// pnpm perf:browser chromium benchmarks: main thread, workers and GPU +// add --record append the results to perf/history and regenerate perf/README.md, keeping each +// benchmark's faster of two runs +// pnpm perf:report regenerate perf/README.md from perf/history +// +// Results are kept per machine, since times only compare on the same hardware. + +import { execFileSync, spawn } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { cpus, platform, arch, totalmem } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const perf = join(root, "perf"); +const historyDirectory = join(perf, "history"); +const descriptionsFile = join(perf, "benchmarks.json"); +const readmeFile = join(perf, "README.md"); +/** A change within this fraction is noise. */ +const noise = 0.1; +/** Records shown in a history table. */ +const shown = 8; + +const flags = new Set(process.argv.slice(2).filter(argument => argument.startsWith("--"))); +const [filter] = process.argv.slice(2).filter(argument => !argument.startsWith("--")); + +const machine = () => { + const cpu = cpus()[0]?.model.trim() ?? "unknown cpu"; + const cores = cpus().length; + const memory = Math.round(totalmem() / 2 ** 30); + const label = `${cpu}, ${cores} cores, ${memory} GB, ${platform()} ${arch()}`; + const key = `${cpu} ${cores} cores ${memory}gb ${platform()} ${arch()}`.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + return { key, label, cpu, cores, memory, platform: platform(), arch: arch() }; +}; + +const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); + +const readJsonLines = file => existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(line => line.trim() !== "").map(line => JSON.parse(line)) : []; +const readDescriptions = () => existsSync(descriptionsFile) ? JSON.parse(readFileSync(descriptionsFile, "utf8")) : {}; +/** "node 24.15.0" is "node"; results compare within a kind across versions. */ +const kindOf = environment => environment.split(" ")[0]; + +const nanosecondsPerN = result => (result.milliseconds * 1e6) / result.n; +/** The fastest copy bandwidth a curve measured: data in the caches. */ +const peak = curve => Math.max(...curve.map(([, rate]) => rate)); +const bestNanosecondsPerN = (best, bandwidth) => { + const rate = best.on === "gpu" ? bandwidth.gpu : best.on === "threads" ? bandwidth.threads && peak(bandwidth.threads) : peak(bandwidth.thread); + return rate === undefined ? undefined : best.bytes / rate; +}; + +const number = value => { + if (value === undefined || !Number.isFinite(value)) return ""; + if (value >= 100) return Math.round(value).toLocaleString("en-US"); + return Number(value.toPrecision(3)).toString(); +}; +const count = value => value >= 1e6 ? `${number(value / 1e6)}M` : value >= 1e3 ? `${number(value / 1e3)}K` : String(value); +const change = (now, before) => { + if (before === undefined) return ""; + const fraction = now / before - 1; + // Past a factor of two, a percentage hides the size of the change. + if (now < before / 2) return `${number(before / now)}× faster`; + if (now > before * 2) return `${number(now / before)}× slower`; + const text = `${fraction >= 0 ? "+" : "−"}${Math.round(Math.abs(fraction) * 100)}%`; + return fraction > noise ? `${text} slower` : fraction < -noise ? `${text} faster` : text; +}; +const ratio = (now, best) => best === undefined ? "" : `${number(now / best)}×`; + +/** The newest result for each benchmark among records, and the one before it, with its record. */ +const latest = records => { + const found = new Map(); + for (let k = records.length - 1; k >= 0; k--) { + for (const [name, result] of Object.entries(records[k].results)) { + const entry = found.get(name); + if (entry === undefined) found.set(name, { result, record: records[k] }); + else if (entry.previous === undefined) entry.previous = result; + } + } + return found; +}; + +/** Runs a command, passing its output through, and collects the perf lines it prints, after the given environment. */ +const collect = (command, args, known, quiet = false) => new Promise((done, failed) => { + const child = spawn(command, args, { cwd: root, stdio: ["ignore", "pipe", "inherit"] }); + let environment = known; + const results = []; + let pending = ""; + const line = text => { + const environmentAt = text.indexOf("perf-environment "); + const resultAt = text.indexOf("perf-result "); + if (environmentAt >= 0) environment = JSON.parse(text.slice(environmentAt + "perf-environment ".length)); + else if (resultAt >= 0) { + const result = JSON.parse(text.slice(resultAt + "perf-result ".length)); + results.push(result); + if (!quiet) progress(environment, result); + } else if (flags.has("--verbose")) console.log(text); + }; + child.stdout.on("data", data => { + pending += data; + const lines = pending.split("\n"); + pending = lines.pop(); + for (const text of lines) line(text); + }); + child.on("close", code => { + if (pending !== "") line(pending); + if (code !== 0) failed(new Error(`${command} ${args.join(" ")} exited with ${code}`)); + else if (environment === undefined) failed(new Error("The run printed no environment.")); + else done({ environment, results }); + }); +}); + +const current = machine(); +const historyFile = join(historyDirectory, `${current.key}.jsonl`); +let previousResults; + +const progress = (environment, result) => { + if (previousResults === undefined) { + const records = readJsonLines(historyFile).filter(record => kindOf(record.environment) === kindOf(environment.environment)); + previousResults = latest(records); + } + const now = nanosecondsPerN(result); + const before = previousResults.get(result.name); + const best = bestNanosecondsPerN(result.best, environment.bandwidth); + console.log([ + result.name.padEnd(34), + `${number(result.milliseconds)} ms`.padStart(12), + `${number(now)} ns/${result.per}`.padEnd(32), + `best ${number(best)}`.padEnd(14), + ratio(now, best).padStart(9), + before === undefined ? "" : ` ${change(now, nanosecondsPerN(before.result))} vs ${before.record.commit}`, + ].join(" ")); +}; + +const run = async () => { + if (flags.has("--browser")) { + execFileSync("pnpm", ["exec", "playwright", "install", "chromium"], { cwd: root, stdio: "ignore" }); + const browserArgs = ["exec", "vitest", "--run", "--config", "vitest.perf.config.ts", "--project", "perf-browser"]; + if (!flags.has("--record")) return collect("pnpm", browserArgs); + // A record keeps each benchmark's faster of two runs, as for node. + const first = await collect("pnpm", browserArgs, undefined, true); + const second = await collect("pnpm", browserArgs, undefined, true); + const results = first.results.map(result => { + const other = second.results.find(({ name }) => name === result.name); + return other !== undefined && other.milliseconds < result.milliseconds ? other : result; + }); + for (const result of results) progress(first.environment, result); + return { environment: first.environment, results }; + } + execFileSync("pnpm", ["exec", "vite", "build", "--config", "vite.perf.config.ts", "--logLevel", "warn"], { cwd: root, stdio: "inherit" }); + // Each benchmark runs in a fresh process, so none inherits another's heap or compiled code. + const { environment } = await collect("node", ["dist/perf/run.js", "--environment"]); + const pattern = filter === undefined ? null : new RegExp(filter); + const names = execFileSync("node", ["dist/perf/run.js", "--names"], { cwd: root, encoding: "utf8" }).split("\n").filter(name => name !== "" && (pattern === null || pattern.test(name))); + // A record keeps the faster of two processes per benchmark, so one disturbed process doesn't stand. + const repeats = flags.has("--record") ? 2 : 1; + const results = []; + for (const name of names) { + let fastest; + for (let k = 0; k < repeats; k++) { + for (const result of (await collect("node", ["dist/perf/run.js", name], environment, true)).results) { + if (fastest === undefined || result.milliseconds < fastest.milliseconds) fastest = result; + } + } + if (fastest === undefined) continue; + progress(environment, fastest); + results.push(fastest); + } + return { environment, results }; +}; + +const record = ({ environment, results }) => { + mkdirSync(historyDirectory, { recursive: true }); + // Records themselves don't make the tree dirty. + const dirty = git("status", "--porcelain", "--", ".", ":!perf") !== ""; + const entry = { + commit: git("rev-parse", "--short", "HEAD"), + dirty, + date: new Date().toISOString(), + machine: current, + environment: environment.environment, + bandwidth: environment.bandwidth, + results: Object.fromEntries(results.map(({ name, milliseconds, n }) => [name, { milliseconds, n }])), + }; + appendFileSync(historyFile, `${JSON.stringify(entry)}\n`); + const descriptions = readDescriptions(); + for (const { name, milliseconds: _, ...description } of results) descriptions[name] = description; + writeFileSync(descriptionsFile, `${JSON.stringify(descriptions, null, 4)}\n`); +}; + +const table = (header, rows) => [ + `| ${header.join(" | ")} |`, + `|${header.map((_, k) => k === 0 ? "---" : "---:").join("|")}|`, + ...rows.map(row => `| ${row.join(" | ")} |`), +].join("\n"); + +const megabytes = bytes => bytes >= 2 ** 20 ? `${number(bytes / 2 ** 20)} MB` : `${number(bytes / 2 ** 10)} KB`; +const curveText = curve => curve.map(([size, rate]) => `${number(rate)} at ${megabytes(size)}`).join(", "); +const commitOf = record => `\`${record.commit}${record.dirty ? "+" : ""}\``; + +const report = () => { + const descriptions = readDescriptions(); + const files = existsSync(historyDirectory) ? readdirSync(historyDirectory).filter(file => file.endsWith(".jsonl")).sort() : []; + const sections = []; + for (const file of files) { + const records = readJsonLines(join(historyDirectory, file)); + if (records.length === 0) continue; + sections.push(`## ${records[records.length - 1].machine.label}`); + const kinds = [...new Set(records.map(record => kindOf(record.environment)))].sort(); + for (const kind of kinds) { + const ofKind = records.filter(record => kindOf(record.environment) === kind); + const found = latest(ofKind); + const newest = ofKind[ofKind.length - 1]; + const { thread, threads, gpu } = newest.bandwidth; + sections.push(`### ${newest.environment}`); + sections.push([ + `Latest ${commitOf(newest)}, ${newest.date.slice(0, 10)}. Copy bandwidth in GB/s by working set:`, + `- One thread: ${curveText(thread)}.`, + ...(threads === undefined ? [] : [`- All workers: ${curveText(threads)}.`]), + ...(gpu === undefined ? [] : [`- GPU: ${number(gpu)}.`]), + ].join("\n")); + const order = Object.keys(descriptions); + const names = [...found.keys()].sort((a, b) => order.indexOf(a) - order.indexOf(b)); + sections.push(table(["Benchmark", "Per", "N", "Typical N", "ms", "ns per N", "Best ns per N", "× best", "Room, ms", "Change"], names.map(name => { + const { result, record, previous } = found.get(name); + const description = descriptions[name]; + const now = nanosecondsPerN(result); + const best = description === undefined ? undefined : bestNanosecondsPerN(description.best, record.bandwidth); + return [ + name, + description?.per ?? "", + count(result.n), + description === undefined ? "" : `${count(description.typical[0])}–${count(description.typical[1])}`, + number(result.milliseconds), + number(now), + number(best), + ratio(now, best), + best === undefined ? "" : number(result.milliseconds - (best * result.n) / 1e6), + previous === undefined ? "" : change(now, nanosecondsPerN(previous)), + ]; + }))); + const recent = ofKind.slice(-shown); + if (recent.length > 1) { + sections.push(`History, ns per N, oldest first:`); + sections.push(table(["Benchmark", ...recent.map(record => `${commitOf(record)} ${record.date.slice(5, 10)}`)], names.map(name => [ + name, + ...recent.map(record => record.results[name] === undefined ? "" : number(nanosecondsPerN(record.results[name]))), + ]))); + } + } + } + const benchmarks = Object.entries(descriptions).map(([name, description]) => [ + `### ${name}`, + "", + description.operation, + "", + `- **N:** ${count(description.n)} ${description.per}; usually ${count(description.typical[0])}–${count(description.typical[1])}.`, + `- **Best:** ${number(description.best.bytes)} bytes per N at the copy bandwidth of ${description.best.on === "threads" ? "all workers" : description.best.on === "gpu" ? "the GPU" : "one thread"}, the fastest it measured. ${description.best.basis}`, + ].join("\n")); + const text = [ + "# ecs performance", + "Generated by `pnpm perf:report` from `history/`; don't edit it by hand.", + [ + "- `pnpm perf [filter]` runs the node benchmarks, bundled as in production, and compares each with this machine's last record. `pnpm perf:browser` runs the chromium ones: main thread, workers and GPU.", + "- Add `--record` to append the results to `history/.jsonl` and regenerate this file. Record on an idle machine, and commit the record with the change it measures.", + "- **ns per N** is the fastest run's time divided by N. **Best** is the time to move the fewest bytes an ideal implementation must, in this ECS's storage model, at the fastest copy bandwidth the run measured, with data in the caches; **× best** is how far from it the run is, and **Room** the milliseconds between them. **Change** compares with the previous record on the same machine; beyond ±10% it says slower or faster.", + ].join("\n"), + ...sections, + "## Benchmarks", + ...benchmarks, + ].join("\n\n"); + writeFileSync(readmeFile, `${text}\n`); +}; + +if (flags.has("--report")) report(); +else { + const results = await run(); + if (flags.has("--record")) { + record(results); + report(); + console.log(`Recorded to ${historyFile}`); + } +} diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts new file mode 100644 index 000000000..0e75a3b92 --- /dev/null +++ b/packages/ecs/src/exports.test.ts @@ -0,0 +1,171 @@ +import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const temporary = mkdtempSync(join(tmpdir(), "ecs-exports-")); +afterAll(() => rmSync(temporary, { recursive: true, force: true })); + +const messages = (diagnostics: readonly ts.Diagnostic[]): string[] => + diagnostics.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + +const installed = join(temporary, "node_modules", "@adobe", "ecs"); + +/** Emits the package's declarations into node_modules, as a consumer installs it. */ +const install = (): void => { + mkdirSync(installed, { recursive: true }); + writeFileSync(join(installed, "package.json"), readFileSync(join(packageRoot, "package.json"))); + const config = ts.getParsedCommandLineOfConfigFile(join(packageRoot, "tsconfig.json"), { outDir: join(installed, "dist"), emitDeclarationOnly: true, composite: false, incremental: false, sourceMap: false }, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} }); + if (config === undefined) throw new Error("No tsconfig.json."); + const program = ts.createProgram([join(packageRoot, "src", "index.ts")], config.options); + // Its own sources check without the test runner's Node types, as a consumer's build sees them. + expect(messages([...ts.getPreEmitDiagnostics(program), ...program.emit().diagnostics])).toEqual([]); + mkdirSync(join(temporary, "node_modules", "@webgpu"), { recursive: true }); + cpSync(join(packageRoot, "node_modules", "@webgpu", "types"), join(temporary, "node_modules", "@webgpu", "types"), { recursive: true, dereference: true }); +}; + +const consumer = ` +import { Archetype, Component, Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs"; +export const position = Symbol("exportsTest/position"); +export const element = Symbol("exportsTest/element"); +export const shown = Symbol("exportsTest/shown"); +declare module "@adobe/ecs" { + interface Components { [position]: { type: number }; [element]: { type: HTMLElement; thread: "main" }; [shown]: { type: true; derived: true } } +} +Component.register(position, { type: { type: "number" } }); +Component.register(element, { thread: "main" }); +Component.register(shown, { type: { const: true }, derived: true }); +export const Placed = Archetype.create([position, element]); +export const showing = Rule.create({ components: { has: [element] }, derive: [shown] }); +export const move = System.create({ name: "exportsTest/move", components: { write: [position] } }, row => { row[position] += 1; }); +export const all = System.create({ name: "exportsTest/all", systems: [move] }); +export const draw = System.render({ name: "exportsTest/draw", shader: { accessors: {} } }, () => {}); +export const placed = Index.create({ name: "exportsTest/placed", key: [position], components: { has: [element] } }); +export const ranked = Index.create({ key: [position], sorted: true }); +export const after = System.create({ components: { read: [position] }, lookup: { next: { index: ranked, read: [position] } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; }); +export const sortedName = (index: Index<{ readonly sorted: true }>): string => index.name; +// A world comes from the one import every other value does. +export const world: World = World.create({ [workers]: 0 }); +// Another thread joins it through the other end of a channel the world shares. +declare const port: MessagePort; +export const joined: World = World.join(port, { threads: ["main"] }); +export const read = System.create({ observer: true, components: { read: [position] }, resources: { read: [gpuDevice] }, lookup: { at: { read: [position] } } }, () => {}); +export const added = world.add(read); +`; + +/** Every declaration file reachable from `file` by relative imports, relative to `directory`. */ +const reachable = (directory: string, file: string): string[] => { + const seen = new Set(); + const pending = [join(directory, file)]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + if (seen.has(next)) continue; + seen.add(next); + for (const [, specifier] of readFileSync(next, "utf8").matchAll(/(?:from|import)\s*\(?\s*["'](\.[^"']+)["']/g)) { + pending.push(join(dirname(next), specifier.replace(/\.js$/, ".d.ts"))); + } + } + return [...seen].map(path => relative(directory, path)).sort(); +}; + +/** Modules only the implementation reads: the registry's read side and the forms of systems it holds. */ +const internal = (path: string): boolean => + path.startsWith("interface/registry/") + || path === "interface/system/composite.d.ts" || path === "interface/system/leaf.d.ts" || path === "interface/system/definition.d.ts" + || path.startsWith("interface/gpu/record"); + +/** Compiles a consumer's `index.ts` against the installed package, emitting its declarations. */ +const compile = (source: string): { readonly messages: string[] } => { + writeFileSync(join(temporary, "index.ts"), source); + const program = ts.createProgram([join(temporary, "index.ts")], { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + lib: ["lib.esnext.d.ts", "lib.dom.d.ts"], + types: ["@webgpu/types"], + strict: true, + declaration: true, + emitDeclarationOnly: true, + outDir: join(temporary, "out"), + }); + const result = program.emit(); + return { messages: messages([...ts.getPreEmitDiagnostics(program), ...result.diagnostics]) }; +}; + +/** A consumer that registers a symbol-keyed component beside the augmentation given. */ +const stringKeyed = (augmentation: string, registration = ""): string => ` +import { Component, Trait } from "@adobe/ecs"; +export const keyed = Symbol("exportsTest/keyed"); +declare module "@adobe/ecs" { + interface Components { [keyed]: { type: number } } + ${augmentation} +} +Component.register(keyed, { type: { type: "number" } }); +${registration} +`; + +/** A consumer with a system whose declaration lists a key in both read and write, and composites of conflicting inputs. */ +const misdeclared = ` +import { Component, System } from "@adobe/ecs"; +export const health = Symbol("exportsTest/health"); +declare module "@adobe/ecs" { + interface Components { [health]: { type: number } } +} +Component.register(health, { type: { type: "number" } }); +export const both = System.create({ components: { read: [health], write: [health] } }, () => {}); +const near = System.create({ input: { type: "object", properties: { distance: { type: "number" } } } }, () => {}); +const far = System.create({ input: { type: "object", properties: { distance: { type: "string" } } } }, () => {}); +export const listed = System.create([near, far]); +export const declared = System.create({ systems: [near, far], after: [both] }); +`; + +describe("package exports", () => { + beforeAll(() => { + install(); + writeFileSync(join(temporary, "package.json"), JSON.stringify({ name: "consumer", type: "module", private: true })); + }, 60_000); + it("let a consumer emit declarations for the values it makes from the public API", () => { + expect(compile(consumer).messages).toEqual([]); + expect(reachable(join(installed, "dist"), "index.d.ts").filter(internal)).toEqual([]); + // An index's type reads as it was declared, naming only the package root. + const emitted = readFileSync(join(temporary, "out", "index.d.ts"), "utf8"); + expect(emitted).toContain(`export declare const placed: Index<{ + readonly name: "exportsTest/placed"; + readonly key: readonly [typeof position]; + readonly components: { + readonly has: readonly [typeof element]; + }; +}>;`); + expect(emitted).toContain(`export declare const ranked: Index<{ + readonly key: readonly [typeof position]; + readonly sorted: true; +}>;`); + expect([...emitted.matchAll(/import\("([^"]+)"\)/g)].map(match => match[1]).filter(path => path !== "@adobe/ecs")).toEqual([]); + }, 60_000); + it("report a misdeclared system, and a composite of conflicting members, at the mistake rather than as no overload matching", () => { + const reported = compile(misdeclared).messages; + expect(reported.some(message => message.includes("No overload matches"))).toBe(false); + expect(reported.filter(message => message.includes(`has a different type in another member of this composite.`))).toHaveLength(4); + expect(reported.some(message => message.startsWith("Type '[unique symbol]' is not assignable"))).toBe(true); + }, 60_000); + it("fail every registration to compile while a component, resource or trait is keyed by a string, naming the keys", () => { + expect(compile(stringKeyed(`interface Components { "exportsTest/named": { type: number } }`)).messages.join("\n")) + .toContain(`'{ readonly "component keys must be symbols": "exportsTest/named"; }'`); + expect(compile(stringKeyed(`interface Resources { "exportsTest/time": { type: number } }`)).messages.join("\n")) + .toContain(`'{ readonly "resource keys must be symbols": "exportsTest/time"; }'`); + const trait = compile(stringKeyed(`interface Traits { readonly persisted: true }`, `Trait.register("persisted");`)).messages.join("\n"); + expect(trait).toContain(`'{ readonly "trait keys must be symbols": "persisted"; }'`); + // Registration takes only symbol keys, so a string-keyed trait can't be registered either. + expect(trait).toContain(`Argument of type '"persisted"' is not assignable`); + }, 60_000); +}); + +describe("the package's dependencies", () => { + it("are none at run time: consumers install nothing with it", () => { + const manifest: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); + const listed = typeof manifest === "object" && manifest !== null ? ["dependencies", "optionalDependencies", "bundledDependencies"].filter(field => field in manifest) : []; + expect(listed).toEqual([]); + }); +}); diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts new file mode 100644 index 000000000..62938ee01 --- /dev/null +++ b/packages/ecs/src/guide/01-resources.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from "vitest"; +import { Entity, Resource, System, World } from "../index.js"; + +// A resource is a key whose value a world holds once. +export const gravity = Symbol("gravity"); +export const names = Symbol("names"); +export const audio = Symbol("audio"); +export const focused = Symbol("focused"); + +// Its traits are declared; `type` is the value's type. +declare module "@adobe/ecs" { + interface Resources { + [gravity]: { type: number }; + [names]: { type: ReadonlyMap }; + [audio]: { type: AudioContext | null }; + [focused]: { type: Entity }; + } +} + +// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a +// value: its schema's default, or one beside it. +Resource.register(gravity, { type: { type: "number", default: -9.8 } }); +Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); +// A value that isn't data has no schema and is stored as it is. A type that allows null starts there. +Resource.register(audio, {}); +// No entity is Entity.none rather than null, so an entity stays data every thread reads. +Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } }); + +// Systems read a world: an observer only reads, after the world's changes apply. +let values: readonly unknown[] = []; +const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { + values = [resources[gravity], resources[names], resources[audio], resources[focused]]; +}); + +it("holds one value per world", async () => { + // A world starts each resource at its default, or at the value given when it's created. + await using earth = World.create(); + await using moon = World.create({ [gravity]: -1.6 }); + // run: runs a system once. + await earth.run(read); + expect(values).toEqual([-9.8, new Map(), null, Entity.none]); + await moon.run(read); + expect(values[0]).toBe(-1.6); +}); diff --git a/packages/ecs/src/guide/02-entities.test.ts b/packages/ecs/src/guide/02-entities.test.ts new file mode 100644 index 000000000..2c96836c1 --- /dev/null +++ b/packages/ecs/src/guide/02-entities.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from "vitest"; +import { Archetype, Component, System, Vec3, World } from "../index.js"; + +// A component is a key whose values entities hold, declared and registered as a resource is. +export const position = Symbol("position"); +export const velocity = Symbol("velocity"); +export const enemy = Symbol("enemy"); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + // A tag: present or not, with no value. + [enemy]: { type: true }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(velocity, { type: Vec3.schema }); +Component.register(enemy, { type: { const: true } }); + +// An archetype names a list of components, once at module scope; it goes wherever a list of components does. +export const Particle = Archetype.create([position, velocity]); + +// Systems spawn entities: name the components, then give their values. +export const spawn = System.create(({ commands }) => { + commands.spawn([position, enemy])({ [position]: [0, 10, 0] }); + commands.spawn(Particle)({ [position]: [0, 0, 0], [velocity]: [1, 0, 0] }); +}); + +// Commands also add and remove components, and despawn entities. +export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => { + commands.remove(row.entity, enemy); +}); + +// An observer system reads them back, once per entity with its components. +const foes: Vec3[] = []; +const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => { + foes.push(row[position]); +}); +let particles = 0; +const countParticles = System.create({ observer: true, components: { has: Particle } }, () => { + particles += 1; +}); + +it("spawns entities with components", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(readFoes); + await world.run(countParticles); + expect(foes).toEqual([[0, 10, 0]]); + expect(particles).toBe(1); + await world.run(defeat); + foes.length = 0; + await world.run(readFoes); + expect(foes).toEqual([]); +}); diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts new file mode 100644 index 000000000..232c17ac0 --- /dev/null +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "vitest"; +import { Component, Report, Resource, System, Vec3, World } from "../index.js"; + +const position = Symbol("position"); +const velocity = Symbol("velocity"); +const frozen = Symbol("frozen"); +const delta = Symbol("delta"); +const frames = Symbol("frames"); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + [frozen]: { type: true }; + } + interface Resources { + [delta]: { type: number }; + [frames]: { type: number }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(velocity, { type: Vec3.schema }); +Component.register(frozen, { type: { const: true } }); +Resource.register(delta, { type: { type: "number", default: 0.5 } }); +Resource.register(frames, { type: { type: "number", default: 0 } }); + +const spawn = System.create(({ commands }) => { + commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); + commands.spawn([position, velocity, frozen])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); +}); + +// Runs once per entity with every component in read and write, and none in not. +const move = System.create({ + // Optional: labels the system in reports. + name: "physics/move", + components: { read: [velocity], write: [position], not: [frozen] }, + resources: { read: [delta] }, +}, (row, { resources }) => { + // Values are replaced whole, never edited in place. + row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); +}); + +// Without components, a system runs once per run. +const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { + resources[frames] += 1; +}); + +// An observer system reads what the runs before it left, and changes nothing: run one to read the world, or add one to +// react to it. +const moved: Vec3[] = []; +let counted = 0; +const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => { + moved.push(row[position]); + counted = resources[frames]; +}); + +it("runs a system once per entity, or once", async () => { + await using world = World.create(); + // run: runs a system once. + await world.run(spawn); + await world.run(move); + await world.run(count); + await world.run(read); + expect(moved).toEqual([[1, 0, 0]]); + expect(counted).toBe(1); +}); + +const fail = System.create({ name: "fail" }, () => { + throw new Error("broken"); +}); + +// A run resolves to its number. Every run and tick has a report, an entity: `Report.run` says what it ran and how long +// it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as +// they change logs them; what observer systems throw is in the next run's report. +const logged: string[] = []; +const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`); +}); + +it("reports what failed as entities", async () => { + await using world = World.create(); + world.add(logFailures); + const run: number = await world.run(fail); + expect(run).toBe(1); + expect(logged).toEqual(["fail: broken"]); +}); diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts new file mode 100644 index 000000000..6aa9cd1ba --- /dev/null +++ b/packages/ecs/src/guide/04-ticks.test.ts @@ -0,0 +1,73 @@ +import { expect, it } from "vitest"; +import { always, cadence, clock, Resource, System, wake, World } from "../index.js"; + +const elapsed = Symbol("elapsed"); +const steps = Symbol("steps"); + +declare module "@adobe/ecs" { + interface Resources { + [elapsed]: { type: number }; + [steps]: { type: number }; + } +} + +Resource.register(elapsed, { type: { type: "number", default: 0 } }); +Resource.register(steps, { type: { type: "number", default: 0 } }); + +// clock is the current tick's: delta is the milliseconds since the last one. +const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => { + resources[elapsed] += resources[clock].delta; +}); + +// Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it. +let reached: (value: number) => void = () => {}; +const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => { + if (resources[elapsed] >= 30) reached(resources[elapsed]); +}); + +let read = { elapsed: 0, delta: 0 }; +const readTime = System.create({ observer: true, resources: { read: [elapsed, clock] } }, ({ resources }) => { + read = { elapsed: resources[elapsed], delta: resources[clock].delta }; +}); + +it("runs added systems on every tick", async () => { + // cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta. + // A tick runs only after a run arrives, unless always is set. + await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true }); + const ticked = new Promise(resolve => { reached = resolve; }); + // add: runs a system on every tick. + world.add(advance); + world.add(watch); + expect(await ticked).toBeGreaterThanOrEqual(30); + await world.run(readTime); + expect(read.delta).toBe(10); + // remove: takes a system added off every tick again. + world.remove(advance); + await world.run(readTime); + const stopped = read.elapsed; + await new Promise(resolve => setTimeout(resolve, 50)); + await world.run(readTime); + expect(read.elapsed).toBe(stopped); +}); + +// A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's +// commands start and a later tick finishes asks for the next one this way. +const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { + resources[steps] += 1; + if (resources[steps] < 3) reduce[wake](resources[clock].now); +}); + +let finished: (value: number) => void = () => {}; +const watchSteps = System.create({ observer: true, resources: { read: [steps], changed: [steps] } }, ({ resources }) => { + if (resources[steps] === 3) finished(resources[steps]); +}); + +it("ticks again when a system asks", async () => { + await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } }); + const done = new Promise(resolve => { finished = resolve; }); + world.add(step); + world.add(watchSteps); + // One run arriving ticks once; step's wakes tick twice more. + await world.run(System.create(() => {})); + expect(await done).toBe(3); +}); diff --git a/packages/ecs/src/guide/05-scheduling.test.ts b/packages/ecs/src/guide/05-scheduling.test.ts new file mode 100644 index 000000000..31b7582cd --- /dev/null +++ b/packages/ecs/src/guide/05-scheduling.test.ts @@ -0,0 +1,60 @@ +import { expect, it } from "vitest"; +import { Component, System, World } from "../index.js"; + +const health = Symbol("health"); +const damage = Symbol("damage"); + +declare module "@adobe/ecs" { + interface Components { + [health]: { type: number }; + [damage]: { type: number }; + } +} + +Component.register(health, { type: { type: "number" } }); +Component.register(damage, { type: { type: "number" } }); + +const spawn = System.create(({ commands }) => { commands.spawn([health, damage])({ [health]: 10, [damage]: 4 }); }); +const hit = System.create({ components: { read: [damage], write: [health] } }, row => { + row[health] -= row[damage]; +}); +// before, after and during list the systems themselves. +const heal = System.create({ components: { write: [health] }, after: [hit] }, row => { + row[health] = Math.min(row[health] * 2, 10); +}); +// A list of systems is a composite: it runs its members as one system, in their order; members without an order +// between them may run in parallel. { systems, after, ... } declares one with a name or constraints. +const frame = System.create([heal, hit]); + +const healths: number[] = []; +const read = System.create({ observer: true, components: { read: [health] } }, row => { + healths.push(row[health]); +}); + +it("orders a composite's members by their constraints", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(frame); + await world.run(read); + // hit, 10 - 4 = 6, then heal, min(12, 10). + expect(healths).toEqual([10]); +}); + +// Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it. +const update = System.create(() => {}); +const render = System.create({ after: [update] }, () => {}); +const poison = System.create({ components: { write: [health] }, during: [update] }, row => { + row[health] -= 1; +}); +const seen: number[] = []; +const report = System.create({ components: { read: [health] }, during: [render] }, row => { + seen.push(row[health]); +}); +const phased = System.create([report, poison, update, render]); + +it("orders systems during phases by the order of the phases", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(phased); + expect(seen).toEqual([9]); +}); diff --git a/packages/ecs/src/guide/06-sync-points.test.ts b/packages/ecs/src/guide/06-sync-points.test.ts new file mode 100644 index 000000000..45dbeb2eb --- /dev/null +++ b/packages/ecs/src/guide/06-sync-points.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; +import { Component, Resource, System, World } from "../index.js"; + +const health = Symbol("health"); +const seen = Symbol("seen"); + +declare module "@adobe/ecs" { + interface Components { + [health]: { type: number }; + } + interface Resources { + [seen]: { type: number }; + } +} + +Component.register(health, { type: { type: "number" } }); +Resource.register(seen, { type: { type: "number", default: 0 } }); + +// Value writes apply at once; commands wait for the sync point that ends each run, then apply in the order issued. +const spawn = System.create(({ commands }) => { + // spawn gives the entity at once, so later commands can name it. + const hero = commands.spawn([health])({ [health]: 3 }); + commands.add(hero, health, 5); + // Spawned and despawned before the sync point, it never exists. + commands.despawn(commands.spawn([health])({ [health]: 1 })); +}); +// A composite is one run with one sync point: count runs after spawn, before its entities exist. +const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => { + resources[seen] += 1; +}); +const frame = System.create([spawn, count]); + +const healths: number[] = []; +// An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing. +const watch = System.create({ observer: true, components: { read: [health] } }, row => { + healths.push(row[health]); +}); +let counted = 0; +const readSeen = System.create({ observer: true, resources: { read: [seen] } }, ({ resources }) => { + counted = resources[seen]; +}); + +it("applies commands at the sync point that ends each run", async () => { + await using world = World.create(); + await world.run(frame); + await world.run(readSeen); + expect(counted).toBe(0); + await world.run(count); + await world.run(readSeen); + expect(counted).toBe(1); +}); + +it("runs observer systems after each sync point", async () => { + healths.length = 0; + await using world = World.create(); + world.add(watch); + await world.run(spawn); + expect(healths).toEqual([5]); +}); diff --git a/packages/ecs/src/guide/07-change-filters.test.ts b/packages/ecs/src/guide/07-change-filters.test.ts new file mode 100644 index 000000000..52c7df14c --- /dev/null +++ b/packages/ecs/src/guide/07-change-filters.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "vitest"; +import { Component, Resource, System, World } from "../index.js"; + +const score = Symbol("score"); +const shown = Symbol("shown"); +const boss = Symbol("boss"); +const level = Symbol("level"); +const paused = Symbol("paused"); +const map = Symbol("map"); +const steps = Symbol("steps"); + +declare module "@adobe/ecs" { + interface Components { + [score]: { type: number }; + [shown]: { type: number }; + [boss]: { type: true }; + } + interface Resources { + [level]: { type: number }; + [paused]: { type: boolean }; + [map]: { type: { readonly width: number } | null }; + [steps]: { type: number }; + } +} + +Component.register(score, { type: { type: "number" } }); +Component.register(shown, { type: { type: "number" } }); +Component.register(boss, { type: { const: true } }); +Resource.register(level, { type: { type: "number", default: 1 } }); +Resource.register(paused, { type: { type: "boolean", default: false } }); +Resource.register(map, { type: { anyOf: [{ type: "object", properties: { width: { type: "number" } } }, { type: "null" }] } }); +Resource.register(steps, { type: { type: "number", default: 0 } }); + +// A resource in changed passes the first run and each run after it changes: no run-once flag needed. +const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => { + commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 }); + for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 }); +}); +// A component in changed passes only rows written since this system last ran. +const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => { + row[shown] = row[score]; +}); +const bump = System.create({ components: { write: [score], has: [boss] } }, row => { + row[score] += 10; +}); + +const shownValues: number[] = []; +const readShown = System.create({ observer: true, components: { read: [shown] } }, row => { + shownValues.push(row[shown]); +}); + +it("runs systems only for what changed since they last ran", async () => { + await using world = World.create(); + await world.run(load); + await world.run(load); + await world.run(readShown); + expect(shownValues).toEqual([-1, -1]); + await world.run(show); + await world.run(bump); + await world.run(show); + shownValues.length = 0; + await world.run(readShown); + expect(shownValues.sort((a, b) => a - b)).toEqual([0, 10]); +}); + +// Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed. +const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => { + resources[steps] += resources[map].width; +}); +const pause = System.create({ resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); +const open = System.create({ resources: { write: [map] } }, ({ resources }) => { resources[map] = { width: 4 }; }); + +let stepped = 0; +const readSteps = System.create({ observer: true, resources: { read: [steps] } }, ({ resources }) => { + stepped = resources[steps]; +}); + +it("runs systems only while their conditions hold", async () => { + await using world = World.create(); + await world.run(step); + await world.run(open); + await world.run(step); + await world.run(pause); + await world.run(step); + await world.run(readSteps); + expect(stepped).toBe(4); +}); diff --git a/packages/ecs/src/guide/08-input.test.ts b/packages/ecs/src/guide/08-input.test.ts new file mode 100644 index 000000000..d1bcf8efc --- /dev/null +++ b/packages/ecs/src/guide/08-input.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; +import { Component, Entity, System, World } from "../index.js"; + +const health = Symbol("health"); + +declare module "@adobe/ecs" { + interface Components { + [health]: { type: number }; + } +} + +Component.register(health, { type: { type: "number" } }); + +let player = Entity.none; +const spawn = System.create(({ commands }) => { player = commands.spawn([health])({ [health]: 10 }); }); + +// input is an object schema of named fields; each run passes a value of it. +const hit = System.create({ + components: { write: [health] }, + input: { type: "object", properties: { amount: { type: "number" } } }, +}, (row, { input }) => { + row[health] -= input.amount; +}); + +// target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. +const heal = System.create({ + components: { write: [health] }, + input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } }, + target: "entity", +}, (row, { input }) => { + row[health] += input.amount; +}); + +const healths: number[] = []; +const read = System.create({ observer: true, components: { read: [health] } }, row => { + healths.push(row[health]); +}); + +it("runs a system with a value", async () => { + await using world = World.create(); + await world.run(spawn); + // Systems with input are run, not added; a composite's input has every field its members declare. + await world.run(hit, { amount: 3 }); + await world.run(heal, { entity: player, amount: 1 }); + await world.run(read); + expect(healths).toEqual([8]); +}); diff --git a/packages/ecs/src/guide/09-rules.test.ts b/packages/ecs/src/guide/09-rules.test.ts new file mode 100644 index 000000000..b75425489 --- /dev/null +++ b/packages/ecs/src/guide/09-rules.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from "vitest"; +import { Component, Rule, System, World } from "../index.js"; + +const mesh = Symbol("mesh"); +const sprite = Symbol("sprite"); +const hidden = Symbol("hidden"); +const drawn = Symbol("drawn"); +const layer = Symbol("layer"); + +declare module "@adobe/ecs" { + interface Components { + [mesh]: { type: number }; + [sprite]: { type: number }; + [hidden]: { type: true }; + // Only rules add derived components, starting at their schema's default. + [drawn]: { type: true; derived: true }; + [layer]: { type: number; derived: true }; + } +} + +Component.register(mesh, { type: { type: "number" } }); +Component.register(sprite, { type: { type: "number" } }); +Component.register(hidden, { type: { const: true } }); +Component.register(drawn, { type: { const: true }, derived: true }); +Component.register(layer, { type: { type: "number", default: 1 }, derived: true }); + +// Entities matching a rule have what it derives, only while they match. +Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); +// Several rules may derive one component, present while any matches. +Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] }); +// Rules build on what others derive. +Rule.create({ components: { has: [drawn] }, derive: [layer] }); + +const spawn = System.create(({ commands }) => { + commands.spawn([mesh])({ [mesh]: 1 }); + commands.spawn([sprite])({ [sprite]: 2 }); + commands.spawn([mesh, hidden])({ [mesh]: 3 }); +}); +const hide = System.create({ components: { has: [sprite] } }, (row, { commands }) => { + commands.add(row.entity, hidden); +}); + +const layers: number[] = []; +const read = System.create({ observer: true, components: { read: [layer], has: [drawn] } }, row => { + layers.push(row[layer]); +}); + +it("derives components from the components an entity has", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(read); + expect(layers).toEqual([1, 1]); + await world.run(hide); + layers.length = 0; + await world.run(read); + expect(layers).toEqual([1]); +}); diff --git a/packages/ecs/src/guide/10-finalizers.test.ts b/packages/ecs/src/guide/10-finalizers.test.ts new file mode 100644 index 000000000..1858fc470 --- /dev/null +++ b/packages/ecs/src/guide/10-finalizers.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from "vitest"; +import { Component, Finalizer, System, World } from "../index.js"; + +const handle = Symbol("handle"); + +declare module "@adobe/ecs" { + interface Components { + [handle]: { type: number }; + } +} + +Component.register(handle, { type: { type: "number" } }); + +/** What handles name outside the world, as GPU buffers and DOM nodes are. */ +const open = new Set(); + +// Runs for each entity that stops matching, whatever the cause, reading its values from before. +Finalizer.create({ components: { read: [handle] } }, row => { + open.delete(row[handle]); +}); + +const spawn = System.create(({ commands }) => { + for (const id of [7, 8]) { + open.add(id); + commands.spawn([handle])({ [handle]: id }); + } +}); +const destroy = System.create({ components: { read: [handle] } }, (row, { commands }) => { + if (row[handle] === 7) commands.despawn(row.entity); + else commands.remove(row.entity, handle); +}); + +it("releases what entities held when they stop matching", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(destroy); + expect([...open]).toEqual([]); +}); + +it("releases what every entity holds when the world is disposed", async () => { + const world = World.create(); + await world.run(spawn); + // dispose runs every finalizer for the entities it still matches. + await world.dispose(); + expect(open.size).toBe(0); +}); + +const saved: string[] = []; + +// Sees each sync point's structural changes to the components it watches, grouped by how entities changed, with +// values before and after: for saving and undo. +Finalizer.changes({ watch: [handle] }, changes => { + for (const group of changes.groups) { + for (const entity of group.entities) saved.push(`${changes.previous(entity, handle)} → ${changes.next(entity, handle)}`); + } +}); + +it("records each sync point's structural changes", async () => { + saved.length = 0; + await using world = World.create(); + await world.run(spawn); + await world.run(destroy); + expect(saved.sort()).toEqual(["7 → null", "8 → null", "null → 7", "null → 8"]); +}); diff --git a/packages/ecs/src/guide/11-traits.test.ts b/packages/ecs/src/guide/11-traits.test.ts new file mode 100644 index 000000000..7ba9f8e6e --- /dev/null +++ b/packages/ecs/src/guide/11-traits.test.ts @@ -0,0 +1,58 @@ +import { expect, it } from "vitest"; +import { Component, Entity, System, World } from "../index.js"; + +const ship = Symbol("ship"); +const parent = Symbol("parent"); +const target = Symbol("target"); +const selected = Symbol("selected"); +const outline = Symbol("outline"); + +// Traits are the fields of a key's declaration; type is one. Registration repeats them. +declare module "@adobe/ecs" { + interface Components { + [ship]: { type: true }; + // Despawned with the entity it points to. + [parent]: { type: Entity; reference: "owner" }; + // Removed when the entity it points to despawns. + [target]: { type: Entity; reference: "weak" }; + [selected]: { type: true }; + // Removed with selected. + [outline]: { type: number; within: typeof selected }; + } +} + +Component.register(ship, { type: { const: true } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(target, { type: Entity.schema, reference: "weak" }); +Component.register(selected, { type: { const: true } }); +Component.register(outline, { type: { type: "number" }, within: selected }); + +const spawn = System.create(({ commands }) => { + const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 }); + commands.spawn([parent])({ [parent]: mothership }); + commands.spawn([target])({ [target]: mothership }); +}); +const deselect = System.create({ components: { has: [selected] } }, (row, { commands }) => { + commands.remove(row.entity, selected); +}); +const destroy = System.create({ components: { has: [ship] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); + +const counts = { outlined: 0, targeting: 0, all: 0 }; +const countOutlined = System.create({ observer: true, components: { has: [outline] } }, () => { counts.outlined += 1; }); +const countTargeting = System.create({ observer: true, components: { has: [target] } }, () => { counts.targeting += 1; }); +const countAll = System.create({ observer: true, components: {} }, () => { counts.all += 1; }); + +it("removes components with their host, and follows references", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(deselect); + await world.run(countOutlined); + expect(counts.outlined).toBe(0); + await world.run(destroy); + await world.run(countTargeting); + await world.run(countAll); + // The owned entity is gone; the weak holder lives on without target. + expect(counts).toEqual({ outlined: 0, targeting: 0, all: 1 }); +}); diff --git a/packages/ecs/src/guide/11-traits.type-test.ts b/packages/ecs/src/guide/11-traits.type-test.ts new file mode 100644 index 000000000..72123c119 --- /dev/null +++ b/packages/ecs/src/guide/11-traits.type-test.ts @@ -0,0 +1,27 @@ +import { Component, Trait } from "../index.js"; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +// A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply. +export const instanced = Symbol("instanced"); +declare module "@adobe/ecs" { + interface Traits { + readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never; + } +} +Trait.register(instanced); + +const transform = Symbol("transform"); +const name = Symbol("name"); +declare module "@adobe/ecs" { + interface Components { + [transform]: { type: readonly [number, number, number]; [instanced]: true }; + [name]: { type: string }; + } +} +Component.register(transform, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, [instanced]: true }); +Component.register(name, { type: { type: "string" } }); + +// Types select components by trait. +check, typeof transform | typeof name>, typeof transform>>(); diff --git a/packages/ecs/src/guide/13-indexes.test.ts b/packages/ecs/src/guide/13-indexes.test.ts new file mode 100644 index 000000000..f30ef85e3 --- /dev/null +++ b/packages/ecs/src/guide/13-indexes.test.ts @@ -0,0 +1,95 @@ +import { expect, it } from "vitest"; +import { Component, Index, System, World } from "../index.js"; + +const cell = Symbol("cell"); +const crowded = Symbol("crowded"); +const score = Symbol("score"); +const player = Symbol("player"); +const serverId = Symbol("serverId"); + +declare module "@adobe/ecs" { + interface Components { + [cell]: { type: readonly [number, number] }; + [crowded]: { type: number }; + [score]: { type: number }; + [player]: { type: string }; + [serverId]: { type: number }; + } +} + +Component.register(cell, { type: { type: "array", items: { type: "integer" }, minItems: 2, maxItems: 2 } }); +Component.register(crowded, { type: { type: "number" } }); +Component.register(score, { type: { type: "number" } }); +Component.register(player, { type: { type: "string" } }); +Component.register(serverId, { type: { type: "integer" } }); + +// An index finds entities by their components' values: here, those with a crowded count, by their cell. +const grid = Index.create({ key: [cell], components: { has: [crowded] } }); + +// A lookup through it gives the rows with a key, in entity order. +const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { + const [x, y] = row[cell]; + row[crowded] = lookup.at([x, y]).length; +}); + +// A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it. +const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } }); + +const winners: string[] = []; +const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => { + winners.length = 0; + const high = lookup.scores({ ">=": 90 }); + for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]); +}); + +const board: string[] = []; +const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => { + board.push(row[player]); +}); + +// A unique index gives one row, or null. +const byServerId = Index.create({ key: [serverId], unique: true }); +let found: number | null = null; +const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => { + found = lookup.byId(input.id)?.entity ?? null; +}); + +// An index without a key holds every entity with its components, in entity order. With changed: true, this observer +// system runs only when an entity entered or left the index, so added, it keeps a count as players come and go. +const players = Index.create({ key: [], components: { has: [player] } }); +let playerCount = 0; +const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { + playerCount = lookup.players().length; +}); + +const crowds: number[] = []; +const readCrowds = System.create({ observer: true, components: { read: [crowded] } }, row => { + crowds.push(row[crowded]); +}); + +const places: readonly (readonly [number, number])[] = [[0, 0], [0, 0], [1, 0]]; +const spawn = System.create(({ commands }) => { + for (const place of places) commands.spawn([cell, crowded])({ [cell]: place, [crowded]: 0 }); + commands.spawn([player, score, serverId])({ [player]: "ada", [score]: 95, [serverId]: 7 }); + commands.spawn([player, score, serverId])({ [player]: "bo", [score]: 80, [serverId]: 8 }); + commands.spawn([player, score, serverId])({ [player]: "cy", [score]: 91, [serverId]: 9 }); +}); + +it("finds rows by key, by range and in order", async () => { + await using world = World.create(); + world.add(countPlayers); + await world.run(spawn); + expect(playerCount).toBe(3); + await world.run(count); + await world.run(readCrowds); + expect(crowds.sort()).toEqual([1, 2, 2]); + await world.run(leaders); + expect(winners).toEqual(["cy", "ada"]); + board.length = 0; + await world.run(list); + expect(board).toEqual(["bo", "cy", "ada"]); + await world.run(find, { id: 8 }); + expect(found).not.toBeNull(); + await world.run(find, { id: 1 }); + expect(found).toBeNull(); +}); diff --git a/packages/ecs/src/guide/CLAUDE.md b/packages/ecs/src/guide/CLAUDE.md new file mode 100644 index 000000000..66eb1607f --- /dev/null +++ b/packages/ecs/src/guide/CLAUDE.md @@ -0,0 +1,34 @@ +# The guide + +`guide.md` in this folder teaches the ECS to someone who has just started using it. The files beside it are the +code it shows: every excerpt comes from a file here that runs as a test, so the guide can't drift from the API. + +## Writing the guide + +- Teach how to use the API, concisely. A reader is learning, so show the common case and stop. +- Prefer concise TypeScript with a few comments for what isn't obvious over prose that explains the code. +- Explain behaviour only as far as using it needs: "you're notified when values change, at most once per frame", not + the exact timing rules. Detailed rules belong in a glossary or appendix, not in a lesson. +- Don't document what throws. Throws aren't part of the documented contract; readers meet them when they happen, and + compile-time checks prevent most mistakes. +- Never mention `@adobe/data` or any other package that isn't this one. It means nothing to a consumer. +- Describe only the current API, with no history. +- Keep it small. Write sections for the basics and intermediate topics; advanced topics are placeholders until the + API settles. + +## Structure + +- **Setup:** its own short section before the basics: the build plugin and how keys are registered and declared. +- **Basics:** resources, then entities, components and archetypes, which together are the whole data model, then + systems, and ticks and cadence: everything needed to build and run a world and its loop. Topics that belong together + share a section; entities are taught with components, and archetypes, named lists of components, with them. +- **Intermediate:** scheduling and composites, sync points, change filters and conditions, input, rules, finalizers + and change sets, and traits with within and references. +- **Advanced:** placeholders only. + +## Code conventions + +- Keys are short: `Symbol("position")`, never a path like `"guide/components/position"`. A resource's description says + nothing about components. +- Archetypes are types, so their names are PascalCase: `const Particle = Archetype.create([position, velocity])`. +- Use the public API only, as a consumer imports it, not internal modules. Read a world through observer systems. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md new file mode 100644 index 000000000..ec0985197 --- /dev/null +++ b/packages/ecs/src/guide/guide.md @@ -0,0 +1,551 @@ +# ecs guide + +How to use the ECS. Each excerpt comes from a test in this folder; the link under it opens the whole file. + +[Setup](#setup) + +**Basics** + +1. [Resources](#1-resources) +2. [Entities, components and archetypes](#2-entities-components-and-archetypes) +3. [Systems](#3-systems) +4. [Ticks and cadence](#4-ticks-and-cadence) + +**Intermediate** + +5. [Scheduling and composites](#5-scheduling-and-composites) +6. [Sync points](#6-sync-points) +7. [Change filters and conditions](#7-change-filters-and-conditions) +8. [Input](#8-input) +9. [Rules](#9-rules) +10. [Finalizers and change sets](#10-finalizers-and-change-sets) +11. [Traits, within and references](#11-traits-within-and-references) + +**Advanced** + +12. [Lookups](#12-lookups) +13. [Indexes](#13-indexes) +14. [Kernels](#14-kernels) +15. [Reductions](#15-reductions) +16. [GPU kernels and render systems](#16-gpu-kernels-and-render-systems) +17. [Hierarchies](#17-hierarchies) +18. [Identity and ids](#18-identity-and-ids) +19. [Threads](#19-threads) + +## Setup + +```ts +// vite.config.ts: compiles kernels, checks each system's declaration against its body, and gives keys and systems ids. +import { kernelPlugin } from "@adobe/ecs/compiler"; +export default defineConfig({ plugins: [kernelPlugin()] }); +``` + +Each component and resource is declared once, by augmenting `Components` or `Resources` in `@adobe/ecs` with its +traits, `type` among them, and registered once with the same traits. Registration's types check the two match. +`type` is registered as a JSON Schema for the value, in which `{ type: "set", items }` and +`{ type: "map", keys, values }` describe a `ReadonlySet` and a `ReadonlyMap`. Traits of your own augment `Traits`. +The examples create worlds with `World.create()`. + +## Basics + +Resources, values a world holds once, and entities, each holding a set of components, are the whole data model. +Systems change that data, once per run or on every tick. + +### 1. Resources + +```ts +export const gravity = Symbol("gravity"); +export const names = Symbol("names"); +export const audio = Symbol("audio"); +export const focused = Symbol("focused"); + +// Its traits are declared; `type` is the value's type. +declare module "@adobe/ecs" { + interface Resources { + [gravity]: { type: number }; + [names]: { type: ReadonlyMap }; + [audio]: { type: AudioContext | null }; + [focused]: { type: Entity }; + } +} + +// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a +// value: its schema's default, or one beside it. +Resource.register(gravity, { type: { type: "number", default: -9.8 } }); +Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); +// A value that isn't data has no schema and is stored as it is. A type that allows null starts there. +Resource.register(audio, {}); +// No entity is Entity.none rather than null, so an entity stays data every thread reads. +Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } }); + +// Systems read a world: an observer only reads, after the world's changes apply. +let values: readonly unknown[] = []; +const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { + values = [resources[gravity], resources[names], resources[audio], resources[focused]]; +}); + +await using moon = World.create({ [gravity]: -1.6 }); +// run: runs a system once. +await moon.run(read); +expect(values[0]).toBe(-1.6); +``` + +[`01-resources.test.ts`](01-resources.test.ts#L5-L45) + +### 2. Entities, components and archetypes + +```ts +export const position = Symbol("position"); +export const velocity = Symbol("velocity"); +export const enemy = Symbol("enemy"); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + // A tag: present or not, with no value. + [enemy]: { type: true }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(velocity, { type: Vec3.schema }); +Component.register(enemy, { type: { const: true } }); + +// An archetype names a list of components, once at module scope; it goes wherever a list of components does. +export const Particle = Archetype.create([position, velocity]); + +// Systems spawn entities: name the components, then give their values. +export const spawn = System.create(({ commands }) => { + commands.spawn([position, enemy])({ [position]: [0, 10, 0] }); + commands.spawn(Particle)({ [position]: [0, 0, 0], [velocity]: [1, 0, 0] }); +}); + +// Commands also add and remove components, and despawn entities. +export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => { + commands.remove(row.entity, enemy); +}); + +// An observer system reads them back, once per entity with its components. +const foes: Vec3[] = []; +const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => { + foes.push(row[position]); +}); +let particles = 0; +const countParticles = System.create({ observer: true, components: { has: Particle } }, () => { + particles += 1; +}); + +await world.run(spawn); +await world.run(readFoes); +await world.run(countParticles); +expect(foes).toEqual([[0, 10, 0]]); +expect(particles).toBe(1); +``` + +[`02-entities.test.ts`](02-entities.test.ts#L5-L58) + +### 3. Systems + +```ts +// Runs once per entity with every component in read and write, and none in not. +const move = System.create({ + // Optional: labels the system in reports. + name: "physics/move", + components: { read: [velocity], write: [position], not: [frozen] }, + resources: { read: [delta] }, +}, (row, { resources }) => { + // Values are replaced whole, never edited in place. + row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); +}); + +// Without components, a system runs once per run. +const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { + resources[frames] += 1; +}); + +// An observer system reads what the runs before it left, and changes nothing: run one to read the world, or add one to +// react to it. +const moved: Vec3[] = []; +let counted = 0; +const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => { + moved.push(row[position]); + counted = resources[frames]; +}); + +await world.run(move); +await world.run(count); +await world.run(read); +expect(moved).toEqual([[1, 0, 0]]); +``` + +[`03-systems.test.ts`](03-systems.test.ts#L34-L68) + +```ts +const fail = System.create({ name: "fail" }, () => { + throw new Error("broken"); +}); + +// A run resolves to its number. Every run and tick has a report, an entity: `Report.run` says what it ran and how long +// it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as +// they change logs them; what observer systems throw is in the next run's report. +const logged: string[] = []; +const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`); +}); + +world.add(logFailures); +const run: number = await world.run(fail); +expect(run).toBe(1); +expect(logged).toEqual(["fail: broken"]); +``` + +[`03-systems.test.ts`](03-systems.test.ts#L70-L86) + +### 4. Ticks and cadence + +```ts +// clock is the current tick's: delta is the milliseconds since the last one. +const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => { + resources[elapsed] += resources[clock].delta; +}); + +// Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it. +let reached: (value: number) => void = () => {}; +const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => { + if (resources[elapsed] >= 30) reached(resources[elapsed]); +}); + +// cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta. +// A tick runs only after a run arrives, unless always is set. +await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true }); +const ticked = new Promise(resolve => { reached = resolve; }); +// add: runs a system on every tick. +world.add(advance); +world.add(watch); +expect(await ticked).toBeGreaterThanOrEqual(30); +// remove: takes a system added off every tick again. +world.remove(advance); +``` + +[`04-ticks.test.ts`](04-ticks.test.ts#L17-L51) + +```ts +// A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's +// commands start and a later tick finishes asks for the next one this way. +const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { + resources[steps] += 1; + if (resources[steps] < 3) reduce[wake](resources[clock].now); +}); + +await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } }); +world.add(step); +// One run arriving ticks once; step's wakes tick twice more. +await world.run(System.create(() => {})); +``` + +[`04-ticks.test.ts`](04-ticks.test.ts#L53-L74) + +## Intermediate + +### 5. Scheduling and composites + +```ts +const hit = System.create({ components: { read: [damage], write: [health] } }, row => { + row[health] -= row[damage]; +}); +// before, after and during list the systems themselves. +const heal = System.create({ components: { write: [health] }, after: [hit] }, row => { + row[health] = Math.min(row[health] * 2, 10); +}); +// A list of systems is a composite: it runs its members as one system, in their order; members without an order +// between them may run in parallel. { systems, after, ... } declares one with a name or constraints. +const frame = System.create([heal, hit]); +``` + +[`05-scheduling.test.ts`](05-scheduling.test.ts#L18-L42) + +```ts +// Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it. +const update = System.create(() => {}); +const render = System.create({ after: [update] }, () => {}); +const poison = System.create({ components: { write: [health] }, during: [update] }, row => { + row[health] -= 1; +}); +const report = System.create({ components: { read: [health] }, during: [render] }, row => { + seen.push(row[health]); +}); +const phased = System.create([report, poison, update, render]); +``` + +[`05-scheduling.test.ts`](05-scheduling.test.ts#L44-L61) + +### 6. Sync points + +```ts +// Value writes apply at once; commands wait for the sync point that ends each run, then apply in the order issued. +const spawn = System.create(({ commands }) => { + // spawn gives the entity at once, so later commands can name it. + const hero = commands.spawn([health])({ [health]: 3 }); + commands.add(hero, health, 5); + // Spawned and despawned before the sync point, it never exists. + commands.despawn(commands.spawn([health])({ [health]: 1 })); +}); +// A composite is one run with one sync point: count runs after spawn, before its entities exist. +const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => { + resources[seen] += 1; +}); +const frame = System.create([spawn, count]); +``` + +[`06-sync-points.test.ts`](06-sync-points.test.ts#L20-L52) + +```ts +// An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing. +const watch = System.create({ observer: true, components: { read: [health] } }, row => { + healths.push(row[health]); +}); +``` + +[`06-sync-points.test.ts`](06-sync-points.test.ts#L35-L38) + +### 7. Change filters and conditions + +```ts +// A resource in changed passes the first run and each run after it changes: no run-once flag needed. +const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => { + commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 }); + for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 }); +}); +// A component in changed passes only rows written since this system last ran. +const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => { + row[shown] = row[score]; +}); +``` + +[`07-change-filters.test.ts`](07-change-filters.test.ts#L35-L65) + +```ts +// Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed. +const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => { + resources[steps] += resources[map].width; +}); +``` + +[`07-change-filters.test.ts`](07-change-filters.test.ts#L67-L88) + +### 8. Input + +```ts +// input is an object schema of named fields; each run passes a value of it. +const hit = System.create({ + components: { write: [health] }, + input: { type: "object", properties: { amount: { type: "number" } } }, +}, (row, { input }) => { + row[health] -= input.amount; +}); + +// target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. +const heal = System.create({ + components: { write: [health] }, + input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } }, + target: "entity", +}, (row, { input }) => { + row[health] += input.amount; +}); + +// Systems with input are run, not added; a composite's input has every field its members declare. +await world.run(hit, { amount: 3 }); +await world.run(heal, { entity: player, amount: 1 }); +``` + +[`08-input.test.ts`](08-input.test.ts#L18-L48) + +### 9. Rules + +```ts +declare module "@adobe/ecs" { + interface Components { + // Only rules add derived components, starting at their schema's default. + [drawn]: { type: true; derived: true }; + [layer]: { type: number; derived: true }; + } +} + +Component.register(drawn, { type: { const: true }, derived: true }); +Component.register(layer, { type: { type: "number", default: 1 }, derived: true }); + +// Entities matching a rule have what it derives, only while they match. +Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); +// Several rules may derive one component, present while any matches. +Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] }); +// Rules build on what others derive. +Rule.create({ components: { has: [drawn] }, derive: [layer] }); +``` + +[`09-rules.test.ts`](09-rules.test.ts#L10-L58) + +### 10. Finalizers and change sets + +```ts +// Runs for each entity that stops matching, whatever the cause, reading its values from before. +Finalizer.create({ components: { read: [handle] } }, row => { + open.delete(row[handle]); +}); + +// dispose runs every finalizer for the entities it still matches. +await world.dispose(); +``` + +[`10-finalizers.test.ts`](10-finalizers.test.ts#L15-L47) + +```ts +// Sees each sync point's structural changes to the components it watches, grouped by how entities changed, with +// values before and after: for saving and undo. +Finalizer.changes({ watch: [handle] }, changes => { + for (const group of changes.groups) { + for (const entity of group.entities) saved.push(`${changes.previous(entity, handle)} → ${changes.next(entity, handle)}`); + } +}); +``` + +[`10-finalizers.test.ts`](10-finalizers.test.ts#L49-L65) + +### 11. Traits, within and references + +```ts +// Traits are the fields of a key's declaration; type is one. Registration repeats them. +declare module "@adobe/ecs" { + interface Components { + [ship]: { type: true }; + // Despawned with the entity it points to. + [parent]: { type: Entity; reference: "owner" }; + // Removed when the entity it points to despawns. + [target]: { type: Entity; reference: "weak" }; + [selected]: { type: true }; + // Removed with selected. + [outline]: { type: number; within: typeof selected }; + } +} + +Component.register(ship, { type: { const: true } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(target, { type: Entity.schema, reference: "weak" }); +Component.register(selected, { type: { const: true } }); +Component.register(outline, { type: { type: "number" }, within: selected }); + +const spawn = System.create(({ commands }) => { + const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 }); + commands.spawn([parent])({ [parent]: mothership }); + commands.spawn([target])({ [target]: mothership }); +}); +``` + +[`11-traits.test.ts`](11-traits.test.ts#L11-L59) + +```ts +// A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply. +export const instanced = Symbol("instanced"); +declare module "@adobe/ecs" { + interface Traits { + readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never; + } +} +Trait.register(instanced); + +// Types select components by trait. +check, typeof transform | typeof name>, typeof transform>>(); +``` + +[`11-traits.type-test.ts`](11-traits.type-test.ts#L6-L27) + +## Advanced + +### 12. Lookups + +Reading another entity's components by id, from a system. To be written. + +### 13. Indexes + +```ts +// An index finds entities by their components' values: here, those with a crowded count, by their cell. +const grid = Index.create({ key: [cell], components: { has: [crowded] } }); + +// A lookup through it gives the rows with a key, in entity order. +const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { + const [x, y] = row[cell]; + row[crowded] = lookup.at([x, y]).length; +}); +``` + +[`13-indexes.test.ts`](13-indexes.test.ts#L27-L34) + +```ts +// A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it. +const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } }); + +const winners: string[] = []; +const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => { + winners.length = 0; + const high = lookup.scores({ ">=": 90 }); + for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]); +}); + +const board: string[] = []; +const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => { + board.push(row[player]); +}); +``` + +[`13-indexes.test.ts`](13-indexes.test.ts#L36-L49) + +```ts +// A unique index gives one row, or null. +const byServerId = Index.create({ key: [serverId], unique: true }); +let found: number | null = null; +const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => { + found = lookup.byId(input.id)?.entity ?? null; +}); +``` + +[`13-indexes.test.ts`](13-indexes.test.ts#L51-L56) + +```ts +// An index without a key holds every entity with its components, in entity order. With changed: true, this observer +// system runs only when an entity entered or left the index, so added, it keeps a count as players come and go. +const players = Index.create({ key: [], components: { has: [player] } }); +let playerCount = 0; +const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { + playerCount = lookup.players().length; +}); +``` + +[`13-indexes.test.ts`](13-indexes.test.ts#L58-L64) + +A list stays valid until its call site runs again. Lists and rows read only what the index holds and its lookup's +`read` names, and `list.changed` tells whether an entity entered, left or moved in it since the system last ran. A +lookup's `changed: true` runs its system only when that happened anywhere in the index. + +### 14. Kernels + +Systems compiled to run across workers. To be written. + +### 15. Reductions + +One value computed from many rows. To be written. + +### 16. GPU kernels and render systems + +Systems that run or draw on the GPU. To be written. + +### 17. Hierarchies + +Running rows parent first, through a reference. To be written. + +### 18. Identity and ids + +How systems and keys stay the same across reloads. To be written. + +### 19. Threads + +Sharing a world with another thread, which joins it to run systems and add observers. To be written. diff --git a/packages/ecs/src/implementation/compiler/access.ts b/packages/ecs/src/implementation/compiler/access.ts new file mode 100644 index 000000000..7a59424a9 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/access.ts @@ -0,0 +1,143 @@ +import type { Node } from 'estree'; +import { entityRead } from './columns.js'; +import { fail } from './failure.js'; +import type { Key } from './key.js'; +import { local, number, type Expression } from './ir.js'; +import { mathConstants, mathFunctions } from './math.js'; +import { scalar } from './scalar.js'; +import type { Scope } from './scope.js'; +import { resolveExport } from './link.js'; +import { resolvedValue } from './module-value.js'; +import type { Out } from './out.js'; +import { describeValue, scalarValue, type Value } from './value.js'; + +const commandNames: ReadonlySet = new Set(["spawn", "despawn", "add", "remove"]); + +const literal = (key: Key): number | string | undefined => key.kind === "static" ? key.name : undefined; + +/** Indexes a plain JS value. A static index into a resource or the input extends its path. */ +const element = (node: Node, scope: Scope, object: Expression, key: Key, optional: boolean): Expression => { + if (key.kind === "static" && !optional && (object.kind === "resource" || object.kind === "args")) { + return { ...object, path: [...object.path, key.name] }; + } + const index: Expression = key.kind === "static" + ? typeof key.name === "number" ? number(key.name) : { kind: "string", value: key.name } + : key.kind === "value" ? scalar(key.value, node, scope) + : fail(node, `${key.name} is not an index here`); + return { kind: "element", object, index, optional }; +}; + +const mathConstant = (name: string): Expression => { + const value: unknown = Reflect.get(Math, name); + return number(typeof value === "number" ? value : NaN); +}; + +/** `value[key]`, resolved at compile time where the kernel subset allows. */ +export const access = (value: Value, key: Key, node: Node, scope: Scope, optional: boolean): Value => { + const signature = scope.unit.signature; + const name = literal(key); + switch (value.kind) { + case "row": + if (key.kind === "binding") { + if (!signature.read.has(key.name) && !signature.write.has(key.name)) fail(node, `${key.name} is not in components read or write`); + return { kind: "component", component: key.name, path: [] }; + } + if (name === "entity") return scalarValue(entityRead(scope.unit)); + return fail(node, "row is indexed by a component in components read or write, or .entity"); + case "context": + if (name === "resources") return { kind: "resources" }; + if (name === "lookup") return { kind: "lookups" }; + if (name === "reduce") return { kind: "reducers" }; + if (name === "commands") return { kind: "commands" }; + if (name === "input" && signature.input) return { kind: "dynamic", expression: { kind: "args", path: [] } }; + if (name === "up" && scope.unit.up !== null) return { kind: "found", lookup: signature.lookups.length, expression: local(scope.unit.up) }; + return fail(node, "the context holds the declared resources, lookup, reduce, input and up, and commands"); + case "resources": { + const index = key.kind === "binding" ? signature.resources.indexOf(key.name) : -1; + if (key.kind !== "binding" || index < 0) return fail(node, `${key.kind === "binding" ? key.name : "resources"} is not in resources read`); + scope.unit.used.resources.add(key.name); + return { kind: "dynamic", expression: { kind: "resource", index, path: [] } }; + } + case "reducers": { + const reduced = key.kind === "binding" ? signature.reduced.findIndex(entry => entry.resource === key.name) : -1; + return reduced >= 0 ? { kind: "reducer", reduced } : fail(node, "reduce is indexed by a resource in the declaration's reduce"); + } + case "lookups": { + const lookup = signature.lookups.findIndex(entry => entry.name === name); + return lookup >= 0 ? { kind: "lookup", lookup } : fail(node, "lookup holds the declaration's lookups, by name"); + } + case "found": { + // The lookup after the declaration's is `up`'s. + const up = value.lookup === signature.lookups.length ? signature.up : null; + const lookup = up ?? signature.lookups[value.lookup]; + // A lookup by entity with `changed` gives it after what it reads. + if (up === null && lookup !== undefined && "watches" in lookup && lookup.watches && name === "changed") { + return { kind: "dynamic", expression: { kind: "element", object: value.expression, index: number(lookup.read.length), optional } }; + } + const index = key.kind === "binding" ? (lookup?.read ?? []).indexOf(key.name) : -1; + if (lookup === undefined || key.kind !== "binding" || index < 0) return fail(node, up === null ? "a lookup result is indexed by a component in that lookup's read, or by changed with changed declared" : "up is indexed by a component in up.read"); + if (up !== null) { + // Read straight from the parent's columns, at the parent's row. + if (optional) return fail(node, "up is read only where it isn't null: test up === null first"); + scope.unit.used.up.add(key.name); + return { kind: "component", component: key.name, path: [], up: true }; + } + else { + const used = scope.unit.used.lookups; + const name = signature.lookups[value.lookup]?.name ?? ""; + used.set(name, (used.get(name) ?? new Set()).add(key.name)); + } + return { kind: "dynamic", expression: { kind: "element", object: value.expression, index: number(index), optional } }; + } + case "indexed": + // A unique index's result is its first row. + if (name === "length") return scalarValue({ kind: "listLength", list: value.list, lookup: value.lookup }); + if (name === "changed") return scalarValue({ kind: "listChanged", list: value.list, lookup: value.lookup }); + if (name === "at") return { kind: "listAt", lookup: value.lookup, list: value.list }; + return access({ kind: "indexRow", lookup: value.lookup, row: value.first }, key, node, scope, optional); + case "indexRow": { + if (name === "entity") return scalarValue({ kind: "rowEntity", row: value.row, lookup: value.lookup }); + const lookup = signature.lookups[value.lookup]; + const read = key.kind === "binding" ? (lookup?.read ?? []).indexOf(key.name) : -1; + if (lookup === undefined || key.kind !== "binding" || read < 0) return fail(node, "a lookup's row is indexed by a component in that lookup's read, or .entity"); + const used = scope.unit.used.lookups; + used.set(lookup.name, (used.get(lookup.name) ?? new Set()).add(key.name)); + return { kind: "rowRead", lookup: value.lookup, row: value.row, read, path: [] }; + } + case "rowRead": + return name === undefined + ? fail(node, "component values are indexed by literal elements or field names") + : { ...value, path: [...value.path, name] }; + case "dynamic": + return { kind: "dynamic", expression: element(node, scope, value.expression, key, optional) }; + case "tuple": + return typeof name === "number" && value.items[name] !== undefined + ? value.items[name] + : fail(node, "tuple elements are indexed by an in-range literal"); + case "struct": { + const field = value.fields.find(([fieldName]) => fieldName === name); + return field ? field[1] : fail(node, `no field ${String(name)}`); + } + case "component": + case "snapshot": + return name === undefined + ? fail(node, "component values are indexed by literal elements or field names") + : { ...value, path: [...value.path, name] }; + case "math": + if (typeof name === "string" && mathConstants.has(name)) return scalarValue(mathConstant(name)); + if (typeof name === "string" && mathFunctions.has(name)) return { kind: "method", owner: "math", name }; + return fail(node, `Math.${String(name)} is not in the kernel subset`); + case "namespace": { + const link = scope.unit.link; + if (typeof name !== "string" || link === null) return fail(node, "namespaces are indexed by an export name"); + const out: Out = []; + const member = resolvedValue(node, resolveExport(link, value.file, name), scope, out); + return out.length === 0 ? member : fail(node, `${name} must be a function or a literal constant`); + } + case "commands": + if (typeof name === "string" && commandNames.has(name)) return { kind: "method", owner: "commands", name }; + return fail(node, "commands has spawn, despawn, add and remove"); + default: + return fail(node, `${describeValue(value)} can't be indexed`); + } +}; diff --git a/packages/ecs/src/implementation/compiler/assign.ts b/packages/ecs/src/implementation/compiler/assign.ts new file mode 100644 index 000000000..7cc089464 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/assign.ts @@ -0,0 +1,167 @@ +import type { AssignmentExpression, Expression as SourceExpression, Node, Pattern, UpdateExpression } from 'estree'; +import { access } from './access.js'; +import { failPattern } from './bind.js'; +import { set } from './booleans.js'; +import { changedColumns, fieldColumn, fieldRead } from './columns.js'; +import { leavesOf, toData, type Data } from './data.js'; +import { fail } from './failure.js'; +import { isBinary, local, number, references, type Expression } from './ir.js'; +import { propertyKey } from './key.js'; +import type { Out } from './out.js'; +import { requireReadable } from './scalar.js'; +import { findLocal, type Scope } from './scope.js'; +import { snapshotLeaf } from './snapshot.js'; +import { fresh } from './unit.js'; +import { describeValue, type Value } from './value.js'; + +/** One scalar store. `component` is set for row writes, which stamp the changed column. */ +type Target = { readonly kind: "local"; readonly local: number } | { readonly kind: "column"; readonly column: number }; +type Store = { readonly target: Target; readonly value: Expression; readonly component: string | undefined }; + +const localTarget = (node: Node, expression: Expression): Target => + expression.kind === "local" ? { kind: "local", local: expression.local } : fail(node, "this can't be assigned in a kernel"); + +const root = (node: Node): string | undefined => + node.type === "Identifier" ? node.name : node.type === "MemberExpression" ? root(node.object) : undefined; + +const requireWritable = (node: Node, scope: Scope, component: string): void => { + if (!scope.unit.signature.write.has(component)) fail(node, `${component} is not in components write`); + scope.unit.used.write.add(component); +}; + +/** Resolves an assignment target to a value whose scalar leaves are assignable. */ +const targetValue = (node: SourceExpression | Pattern, scope: Scope, out: Out): Value => { + const value = scope.unit.expression(node, scope, out); + if (value.kind === "component") { + if (value.up === true) return fail(node, "up is read-only"); + requireWritable(node, scope, value.component); + // Values are immutable: a write replaces the whole component value. + if (value.path.length > 0) return fail(node, "component values are immutable; write the whole value"); + return value; + } + if (node.type !== "Identifier") return fail(node, "values are immutable; assign a whole new value to a let local"); + const name = root(node); + const local = name === undefined ? undefined : findLocal(scope, name); + if (value.kind === "dynamic" && (local?.mutable !== true || node.type !== "Identifier")) return fail(node, "resources, args and lookup results are read-only in kernels"); + if (local === undefined) return fail(node, "this can't be assigned in a kernel"); + if (!local.mutable) return fail(node, `${name} is const`); + return value; +}; + +/** Pairs a target with assigned data, leaf by leaf. */ +const pair = (node: Node, target: Value, data: Data, scope: Scope): readonly Store[] => { + const unit = scope.unit; + switch (target.kind) { + case "component": { + const leaves = leavesOf(data); + if (target.path.length === 0 && data.kind !== "leaf") { + unit.shapes.add(`{ component: ${target.component}, leaves: ${JSON.stringify(leaves.map(leaf => leaf.path))} }`); + } + return leaves.map(leaf => ({ + target: { kind: "column", column: fieldColumn(unit, target.component, [...target.path, ...leaf.path]) }, value: leaf.expression, component: target.component, + })); + } + case "snapshot": + return leavesOf(data).map(leaf => ({ + target: { kind: "local", local: snapshotLeaf(unit, target.snapshot, [...target.path, ...leaf.path]) }, value: leaf.expression, component: undefined, + })); + case "scalar": case "dynamic": + return data.kind === "leaf" ? [{ target: localTarget(node, target.expression), value: data.expression, component: undefined }] : fail(node, "can't assign a tuple or struct to a scalar"); + case "tuple": + return data.kind === "tuple" && data.items.length === target.items.length + ? target.items.flatMap((item, index) => pair(node, item, data.items[index], scope)) + : fail(node, "the assigned value must have the same shape"); + case "struct": + if (data.kind !== "struct") return fail(node, "the assigned value must have the same shape"); + return target.fields.flatMap(([name, field]) => { + const source = data.fields.find(([sourceName]) => sourceName === name); + return source ? pair(node, field, source[1], scope) : fail(node, `the assigned value has no field ${name}`); + }); + default: + return fail(node, `can't assign to ${describeValue(target)}`); + } +}; + +const collect = (pattern: Pattern, value: Value, scope: Scope, out: Out): readonly Store[] => { + switch (pattern.type) { + case "ArrayPattern": + return pattern.elements.flatMap((element, index) => element === null ? [] + : element.type === "RestElement" ? fail(element, "rest elements are not in the kernel subset") + : collect(element, access(value, { kind: "static", name: index }, element, scope, false), scope, out)); + case "ObjectPattern": + return pattern.properties.flatMap(property => property.type === "RestElement" + ? fail(property, "rest elements are not in the kernel subset") + : collect(property.value, access(value, propertyKey(property, scope, out), property, scope, false), scope, out)); + case "Identifier": case "MemberExpression": + return pair(pattern, targetValue(pattern, scope, out), toData(value, pattern, scope), scope); + default: + return failPattern(pattern); + } +}; + +/** Whether `value` reads what `target` writes. */ +const reads = (value: Expression, target: Target): boolean => references(value, node => target.kind === "local" + ? node.kind === "local" && node.local === target.local + : node.kind === "column" && node.column === target.column); + +/** + * Emits stores as if every right-hand leaf were evaluated first, so `[a, b] = [b, a]` swaps: a leaf that reads an + * earlier store's target is copied to a temporary first. Each written component is stamped once. + */ +const emit = (stores: readonly Store[], scope: Scope, out: Out): void => { + const unit = scope.unit; + const values = stores.map((store, index) => { + if (!stores.slice(0, index).some(earlier => reads(store.value, earlier.target))) return store.value; + const temporary = fresh(unit, "t"); + out.push(set(unit, temporary, store.value, "const")); + return local(temporary); + }); + stores.forEach((store, index) => out.push(store.target.kind === "local" + ? set(unit, store.target.local, values[index], undefined) + : { kind: "store", column: store.target.column, value: values[index] })); + for (const component of new Set(stores.flatMap(store => store.component === undefined ? [] : [store.component]))) { + out.push({ kind: "stamp", ...changedColumns(unit, component) }); + } +}; + +/** A scalar read-modify-write target. */ +const compoundStore = (node: SourceExpression | Pattern, operator: string, operand: Expression, scope: Scope, out: Out): Store => { + if (!isBinary(operator)) return fail(node, `${operator}= is not in the kernel subset`); + const target = targetValue(node, scope, out); + const unit = scope.unit; + const combine = (current: Expression): Expression => ({ kind: "binary", operator, left: current, right: operand }); + switch (target.kind) { + case "component": + requireReadable(node, scope, target.component); + return { + target: { kind: "column", column: fieldColumn(unit, target.component, target.path) }, + value: combine(fieldRead(unit, target.component, target.path)), component: target.component, + }; + case "snapshot": { + const leaf = snapshotLeaf(unit, target.snapshot, target.path); + return { target: { kind: "local", local: leaf }, value: combine(local(leaf)), component: undefined }; + } + case "scalar": case "dynamic": + return { target: localTarget(node, target.expression), value: combine(target.expression), component: undefined }; + default: + return fail(node, `compound assignment needs a scalar, not ${describeValue(target)}`); + } +}; + +const logical = new Set(["&&=", "||=", "??="]); + +/** Compiles an assignment statement. */ +export const assign = (node: AssignmentExpression, scope: Scope, out: Out): void => { + if (node.operator === "=") { + const value = scope.unit.expression(node.right, scope, out); + return emit(collect(node.left, value, scope, out), scope, out); + } + if (logical.has(node.operator)) return fail(node, `${node.operator} is not in the kernel subset`); + const operand = toData(scope.unit.expression(node.right, scope, out), node.right, scope); + if (operand.kind !== "leaf") return fail(node.right, "compound assignment needs a scalar"); + emit([compoundStore(node.left, node.operator.slice(0, -1), operand.expression, scope, out)], scope, out); +}; + +/** Compiles `x++`, `--x` and friends as statements. */ +export const update = (node: UpdateExpression, scope: Scope, out: Out): void => + emit([compoundStore(node.argument, node.operator === "++" ? "+" : "-", number(1), scope, out)], scope, out); diff --git a/packages/ecs/src/implementation/compiler/bind.ts b/packages/ecs/src/implementation/compiler/bind.ts new file mode 100644 index 000000000..5960c4d2e --- /dev/null +++ b/packages/ecs/src/implementation/compiler/bind.ts @@ -0,0 +1,87 @@ +import type { Identifier, Node, Pattern } from 'estree'; +import { access } from './access.js'; +import { set } from './booleans.js'; +import { componentRead } from './columns.js'; +import { dataValue, mapLeaves, toData } from './data.js'; +import { fail } from './failure.js'; +import { local, type Expression } from './ir.js'; +import { propertyKey } from './key.js'; +import type { Out } from './out.js'; +import { requireReadable } from './scalar.js'; +import type { Scope } from './scope.js'; +import { createSnapshot, snapshotDeclaration, snapshotLeaf } from './snapshot.js'; +import { fresh } from './unit.js'; +import { describeValue, type Value } from './value.js'; + +/** How a local is bound. Parameters are mutable data, but may also alias non-data values like `row`. */ +export type Binding = "const" | "let" | "parameter"; + +const isLiteral = (expression: Expression): boolean => + expression.kind === "number" || expression.kind === "boolean" || expression.kind === "nullish"; + +/** The value a new local holds: data is copied into fresh locals, so later writes elsewhere don't change it. */ +const declared = (node: Identifier, value: Value, binding: Binding, scope: Scope, out: Out): Value => { + const keyword = binding === "const" ? "const" : "let"; + const unit = scope.unit; + const copy = (expression: Expression): Expression => { + if (binding === "const" && isLiteral(expression)) return expression; + const target = fresh(unit, node.name); + out.push(set(unit, target, expression, keyword)); + return local(target); + }; + switch (value.kind) { + case "scalar": + return { kind: "scalar", expression: copy(value.expression) }; + case "dynamic": { + // Resources and args don't change while a kernel runs, so a const can alias them. + const constant = value.expression.kind === "resource" || value.expression.kind === "args"; + return binding === "const" && constant ? value : { kind: "dynamic", expression: copy(value.expression) }; + } + case "tuple": case "struct": + return dataValue(mapLeaves(toData(value, node, scope), copy)); + case "component": { + if (value.up !== true) requireReadable(node, scope, value.component); + const snapshot = createSnapshot(keyword, node.name, path => componentRead(unit, value, path)); + out.push(snapshotDeclaration(snapshot)); + return { kind: "snapshot", snapshot, path: [] }; + } + case "snapshot": { + const source = value; + const snapshot = createSnapshot(keyword, node.name, path => local(snapshotLeaf(unit, source.snapshot, [...source.path, ...path]))); + out.push(snapshotDeclaration(snapshot)); + return { kind: "snapshot", snapshot, path: [] }; + } + // A row's values are read where they're used: a const reads them lazily, a let holds a copy. + case "rowRead": + return binding === "let" ? dataValue(mapLeaves(toData(value, node, scope), copy)) : value; + default: + return binding === "let" ? fail(node, `a let can't hold ${describeValue(value)}; use const`) : value; + } +}; + +/** Binds a declaration or parameter pattern to a value. */ +export const bind = (pattern: Pattern, value: Value, binding: Binding, scope: Scope, out: Out): void => { + switch (pattern.type) { + case "Identifier": + scope.locals.set(pattern.name, { value: declared(pattern, value, binding, scope, out), mutable: binding !== "const" }); + return; + case "ArrayPattern": + pattern.elements.forEach((element, index) => { + if (element === null) return; + if (element.type === "RestElement") return fail(element, "rest elements are not in the kernel subset"); + bind(element, access(value, { kind: "static", name: index }, element, scope, false), binding, scope, out); + }); + return; + case "ObjectPattern": + for (const property of pattern.properties) { + if (property.type === "RestElement") return fail(property, "rest elements are not in the kernel subset"); + bind(property.value, access(value, propertyKey(property, scope, out), property, scope, false), binding, scope, out); + } + return; + default: + return failPattern(pattern); + } +}; + +export const failPattern = (pattern: Node): never => + fail(pattern, pattern.type === "AssignmentPattern" ? "default values are not in the kernel subset" : `${pattern.type} is not in the kernel subset`); diff --git a/packages/ecs/src/implementation/compiler/body-access.ts b/packages/ecs/src/implementation/compiler/body-access.ts new file mode 100644 index 000000000..95c545725 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/body-access.ts @@ -0,0 +1,332 @@ +import type { Node, ObjectExpression, Pattern } from 'estree'; +import { children, declaredIn, patternNames } from './estree.js'; +import type { Used } from './exact.js'; +import type { ModuleScope } from './module-scope.js'; + +const unknown: Used = { query: null, resources: null, lookups: null }; + +/** Whether `node`, a child of `parent`, names a property or label, or declares a binding, rather than referring to one. */ +const isName = (node: Node, parent: Node | undefined): boolean => { + switch (parent?.type) { + case "MemberExpression": return parent.property === node && !parent.computed; + case "Property": return parent.key === node && !parent.computed && !parent.shorthand; + case "MethodDefinition": case "PropertyDefinition": return parent.key === node && !parent.computed; + case "LabeledStatement": case "BreakStatement": case "ContinueStatement": return true; + case "VariableDeclarator": return parent.id === node; + default: return false; + } +}; + +/** How `member`, a `name[key]` expression, is used: read, written, or both; null when it's a destructuring target or deleted. */ +const useOf = (member: Node, parent: Node | undefined): { readonly read: boolean; readonly write: boolean } | null => { + switch (parent?.type) { + case "AssignmentExpression": + return parent.left !== member ? { read: true, write: false } : { read: parent.operator !== "=", write: true }; + case "UpdateExpression": return { read: true, write: true }; + case "UnaryExpression": return parent.operator === "delete" ? null : { read: true, write: false }; + case "ArrayPattern": case "RestElement": case "AssignmentPattern": case "ForInStatement": case "ForOfStatement": + return null; + case "Property": return parent.value === member && parent.shorthand ? null : { read: true, write: false }; + default: return { read: true, write: false }; + } +}; + +/** Whether a lookup result `value`, a child of `parent`, is only tested: compared with null, or used as a condition. */ +const isTested = (value: Node, parent: Node | undefined): boolean => { + switch (parent?.type) { + case "BinaryExpression": { + const other = parent.left === value ? parent.right : parent.left; + return ["===", "!==", "==", "!="].includes(parent.operator) && other.type === "Literal" && other.value === null; + } + case "IfStatement": case "ConditionalExpression": case "WhileStatement": return parent.test === value; + case "UnaryExpression": return parent.operator === "!"; + case "LogicalExpression": return parent.operator === "&&" && parent.left === value; + default: return false; + } +}; + +const isFunctionBody = (node: Node): node is Node & { readonly type: "ArrowFunctionExpression" | "FunctionExpression" } => + node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression"; + +const mentions = (node: Node, name: string): boolean => + (node.type === "Identifier" && node.name === name) || children(node).some(child => mentions(child, name)); + +/** The plain names of a declaration's lookups, or null when `lookup` isn't an object literal of them. */ +const lookupNames = (declaration: ObjectExpression): readonly string[] | null => { + const lookup = declaration.properties.find(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "lookup"); + if (lookup === undefined) return []; + if (lookup.type !== "Property" || lookup.value.type !== "ObjectExpression") return null; + const names = lookup.value.properties.map(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" ? property.key.name : null); + return names.every(name => name !== null) ? names.filter(name => name !== null) : null; +}; + +/** The names of a declaration's lookups through an index: those declared as an object literal with `index`. */ +const indexLookupNames = (declaration: ObjectExpression): ReadonlySet => { + const lookup = declaration.properties.find(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "lookup"); + if (lookup?.type !== "Property" || lookup.value.type !== "ObjectExpression") return new Set(); + return new Set(lookup.value.properties.flatMap(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.value.type === "ObjectExpression" + && property.value.properties.some(field => field.type === "Property" && !field.computed && field.key.type === "Identifier" && field.key.name === "index") + ? [property.key.name] : [])); +}; + +type Group = "resources" | "lookup" | "reduce"; + +const isGroup = (name: string): name is Group => name === "resources" || name === "lookup" || name === "reduce"; + +/** + * What a main-thread body reads, writes and contributes to through its row and context, for the parts where every use + * shows in its source. `row` and `context` are the indexes of those parameters, or -1 for none. The row and the + * context's `resources`, `lookup` and `reduce` are indexed only by module-scope keys the body doesn't shadow, or + * destructured by them, and never passed on, aliased or spread. A lookup's results are followed where each is indexed + * by such keys or tested, directly or through a `const` declared once; a lookup through an index's lists where they're + * read for `length` and `changed`, and their rows from `at` as a lookup's results are. + */ +export const usedBy = (declaration: ObjectExpression, body: Node, module: ModuleScope, positions: { readonly row: number; readonly context: number }): Used => { + if (!isFunctionBody(body)) return unknown; + if (body.type === "FunctionExpression" && mentions(body.body, "arguments")) return unknown; + const counts = new Map(); + declaredIn(body.body, name => counts.set(name, (counts.get(name) ?? 0) + 1)); + const parameters = new Set(body.params.flatMap(parameter => patternNames(parameter))); + const isKey = (name: string): boolean => module.has(name) && !counts.has(name) && !parameters.has(name); + const lookups = lookupNames(declaration); + const indexed = indexLookupNames(declaration); + + const query = { read: new Set(), write: new Set() }; + const resources = { read: new Set(), write: new Set(), reduce: new Set() }; + const lookupReads = new Map | null>((lookups ?? []).map(name => [name, new Set()])); + let queryKnown = true; + let resourcesKnown = true; + let lookupsKnown = lookups !== null; + const unknownLookup = (lookup: string): void => { lookupReads.set(lookup, null); }; + const unknownGroup = (group: Group): void => { + if (group === "lookup") lookupsKnown = false; + else resourcesKnown = false; + }; + + /** Names bound to a context group, to a lookup function, or to a lookup's result. */ + const groups = new Map(); + const callers = new Map(); + const results = new Map(); + /** Names bound to a row an index lookup's list gave, by lookup. */ + const rows = new Map(); + /** The initializers `collect` followed: the context or a group, destructured or bound to a name. */ + const followed = new Set(); + + /** Reads `pattern`'s computed module-scope keys; false when it does anything else. Named properties go to `named`. */ + const destructure = (pattern: Pattern, named: (property: string, value: Pattern) => boolean, keyed: (key: string) => void): boolean => { + if (pattern.type !== "ObjectPattern") return false; + for (const property of pattern.properties) { + if (property.type === "RestElement" || property.key.type !== "Identifier") return false; + if (!property.computed) { + if (!named(property.key.name, property.value)) return false; + } + else if (isKey(property.key.name)) keyed(property.key.name); + else return false; + } + return true; + }; + const rowNamed = (property: string): boolean => property === "entity"; + const lookupNamed = (property: string, value: Pattern): boolean => { + if (lookups === null || !lookups.includes(property)) return false; + if (value.type === "Identifier" && counts.get(value.name) === (parameters.has(value.name) ? undefined : 1)) callers.set(value.name, property); + else unknownLookup(property); + return true; + }; + /** Destructures a group: lookups by name, resources by key as reads, and reductions not at all. */ + const destructureGroup = (group: Group, pattern: Pattern): void => { + if (group === "lookup") { + if (!destructure(pattern, lookupNamed, () => { lookupsKnown = false; })) lookupsKnown = false; + } + else if (group === "resources") { + if (!destructure(pattern, () => false, key => resources.read.add(key))) resourcesKnown = false; + } + else resourcesKnown = false; + }; + /** Binds the context's groups from a destructuring of it; `declaredOnce` says whether a bound name is safe to follow. */ + const contextNamed = (declaredOnce: (name: string) => boolean) => (property: string, value: Pattern): boolean => { + if (!isGroup(property)) return true; + if (value.type === "Identifier" && declaredOnce(value.name)) groups.set(value.name, property); + else if (value.type === "ObjectPattern") destructureGroup(property, value); + else unknownGroup(property); + return true; + }; + const unknownContext = (): void => { + resourcesKnown = false; + lookupsKnown = false; + }; + + const row = positions.row < 0 ? undefined : body.params[positions.row]; + const context = positions.context < 0 ? undefined : body.params[positions.context]; + const rowName = row?.type === "Identifier" && !counts.has(row.name) ? row.name : null; + const contextName = context?.type === "Identifier" && !counts.has(context.name) ? context.name : null; + if (row !== undefined && rowName === null && !destructure(row, rowNamed, key => query.read.add(key))) queryKnown = false; + if (context !== undefined && contextName === null && !destructure(context, contextNamed(name => !counts.has(name)), () => unknownContext())) unknownContext(); + + /** A lookup's row `value`, a child of `ancestors[at]`: indexed by a key, read for `entity`, bound to a `const`, or tested. */ + const rowOf = (lookup: string, value: Node, ancestors: readonly Node[], at: number): void => { + const parent = ancestors[at]; + if (parent?.type === "MemberExpression" && parent.object === value) { + if (parent.computed && parent.property.type === "Identifier" && isKey(parent.property.name)) lookupReads.get(lookup)?.add(parent.property.name); + else if (!(indexed.has(lookup) && !parent.computed && parent.property.type === "Identifier" && parent.property.name === "entity")) unknownLookup(lookup); + } + else if (parent?.type === "VariableDeclarator" && parent.init === value && parent.id.type === "Identifier" + && (results.get(parent.id.name) === lookup || rows.get(parent.id.name) === lookup)) return; + else if (!isTested(value, parent)) unknownLookup(lookup); + }; + /** + * A lookup's result `value`, a child of `ancestors[at]`: a row as `rowOf` takes it, or an index lookup's list, read + * for `length` and `changed`, its rows from `at` taken as rows. + */ + const result = (lookup: string, value: Node, ancestors: readonly Node[], at: number): void => { + const parent = ancestors[at]; + if (indexed.has(lookup) && parent?.type === "MemberExpression" && parent.object === value && !parent.computed && parent.property.type === "Identifier") { + const { name } = parent.property; + if (name === "length" || name === "changed") return; + const call = ancestors[at - 1]; + if (name === "at" && call?.type === "CallExpression" && call.callee === parent) return rowOf(lookup, call, ancestors, at - 2); + } + rowOf(lookup, value, ancestors, at); + }; + /** A reference to a lookup's function, `callee`, a child of `ancestors[at]`. */ + const called = (lookup: string, callee: Node, ancestors: readonly Node[], at: number): void => { + const parent = ancestors[at]; + if (parent?.type === "CallExpression" && parent.callee === callee) result(lookup, parent, ancestors, at - 1); + else unknownLookup(lookup); + }; + + /** The group `node` is: a name bound to one, or `context.group`. */ + const groupAt = (node: Node): Group | undefined => { + if (node.type === "Identifier") return groups.get(node.name); + if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === contextName + && node.property.type === "Identifier" && isGroup(node.property.name)) return node.property.name; + return undefined; + }; + /** The lookup a call calls, when it's a lookup's function by name or through its group. */ + const calledLookup = (call: Node): string | undefined => { + if (call.type !== "CallExpression") return undefined; + const { callee } = call; + if (callee.type === "Identifier") return callers.get(callee.name); + if (callee.type === "MemberExpression" && !callee.computed && groupAt(callee.object) === "lookup" + && callee.property.type === "Identifier" && lookups?.includes(callee.property.name)) return callee.property.name; + return undefined; + }; + /** The index lookup whose list's `at` a call calls: on a name bound to the list, or on the lookup's call itself. */ + const rowCall = (call: Node): string | undefined => { + if (call.type !== "CallExpression" || call.callee.type !== "MemberExpression" || call.callee.computed + || call.callee.property.type !== "Identifier" || call.callee.property.name !== "at") return undefined; + const list = call.callee.object; + const lookup = list.type === "Identifier" ? results.get(list.name) : calledLookup(list); + return lookup !== undefined && indexed.has(lookup) ? lookup : undefined; + }; + /** Finds groups and lookups bound by `const` declarations declared once, and `const`s holding a lookup's result, before any reference is classified. */ + const collect = (node: Node, kind: string | undefined): void => { + if (node.type === "VariableDeclarator" && kind === "const" && node.init) { + const once = (name: string): boolean => counts.get(name) === 1; + const group = groupAt(node.init); + if (node.id.type === "Identifier" && once(node.id.name)) { + const lookup = calledLookup(node.init); + if (lookup !== undefined) results.set(node.id.name, lookup); + const listed = rowCall(node.init); + if (listed !== undefined) rows.set(node.id.name, listed); + if (group !== undefined) { + groups.set(node.id.name, group); + followed.add(node.init); + } + } + else if (node.init.type === "Identifier" && node.init.name === contextName) { + if (destructure(node.id, contextNamed(once), () => unknownContext())) followed.add(node.init); + } + else if (group !== undefined) { + destructureGroup(group, node.id); + followed.add(node.init); + } + } + for (const child of children(node)) collect(child, node.type === "VariableDeclaration" ? node.kind : kind); + }; + collect(body.body, undefined); + + /** A use of group `group` at `node`, a child of `ancestors[at]`. */ + const useGroup = (group: Group, node: Node, ancestors: readonly Node[], at: number): void => { + if (followed.has(node)) return; + const parent = ancestors[at]; + const grandparent = ancestors[at - 1]; + if (parent?.type !== "MemberExpression" || parent.object !== node) return unknownGroup(group); + const { property } = parent; + if (group === "lookup") { + if (!parent.computed && property.type === "Identifier" && lookups?.includes(property.name)) called(property.name, parent, ancestors, at - 1); + else lookupsKnown = false; + return; + } + if (!parent.computed || property.type !== "Identifier" || !isKey(property.name)) return unknownGroup(group); + if (group === "reduce") { + if (grandparent?.type === "CallExpression" && grandparent.callee === parent) resources.reduce.add(property.name); + else resourcesKnown = false; + return; + } + const use = useOf(parent, grandparent); + if (use === null) return unknownGroup(group); + if (use.read) resources.read.add(property.name); + if (use.write) resources.write.add(property.name); + }; + + /** Classifies a reference; `ancestors` ends with its parent. */ + const reference = (node: Node & { readonly type: "Identifier" }, ancestors: readonly Node[]): void => { + const { name } = node; + const parent = ancestors.at(-1); + const grandparent = ancestors.at(-2); + if (name === rowName) { + if (parent?.type === "MemberExpression" && parent.object === node) { + const { property } = parent; + if (!parent.computed) { + if (property.type !== "Identifier" || !rowNamed(property.name)) queryKnown = false; + return; + } + const use = property.type === "Identifier" && isKey(property.name) ? useOf(parent, grandparent) : null; + if (use === null || property.type !== "Identifier") queryKnown = false; + else { + if (use.read) query.read.add(property.name); + if (use.write) query.write.add(property.name); + } + } + else if (!(parent?.type === "VariableDeclarator" && parent.init === node && destructure(parent.id, rowNamed, key => query.read.add(key)))) queryKnown = false; + } + else if (name === contextName) { + if (parent?.type === "MemberExpression" && parent.object === node && !parent.computed && parent.property.type === "Identifier") { + const property = parent.property.name; + if (isGroup(property)) useGroup(property, parent, ancestors, ancestors.length - 2); + return; + } + if (!followed.has(node)) unknownContext(); + } + else { + const at = ancestors.length - 1; + const group = groups.get(name); + if (group !== undefined) return useGroup(group, node, ancestors, at); + const caller = callers.get(name); + if (caller !== undefined) return called(caller, node, ancestors, at); + const lookup = results.get(name); + if (lookup !== undefined) return result(lookup, node, ancestors, at); + const row = rows.get(name); + if (row !== undefined) rowOf(row, node, ancestors, at); + } + }; + + const ancestors: Node[] = []; + /** A declaration's target pattern only binds names, so only its initializer holds references. */ + const visit = (node: Node): void => { + if (node.type === "Identifier" && !isName(node, ancestors.at(-1))) reference(node, ancestors); + ancestors.push(node); + for (const child of node.type === "VariableDeclarator" ? (node.init === null || node.init === undefined ? [] : [node.init]) : children(node)) visit(child); + ancestors.pop(); + }; + visit(body.body); + return { + query: queryKnown ? query : null, + resources: resourcesKnown ? resources : null, + lookups: lookupsKnown ? lookupReads : null, + }; +}; diff --git a/packages/ecs/src/implementation/compiler/booleans.ts b/packages/ecs/src/implementation/compiler/booleans.ts new file mode 100644 index 000000000..3f50eb508 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/booleans.ts @@ -0,0 +1,38 @@ +import type { Expression, Statement } from './ir.js'; +import { references } from './ir.js'; +import type { Unit } from './unit.js'; + +/** + * Column reads print as plain `c{k}[i]`, since a 0/1 number behaves like a boolean in most positions. Where a + * boolean's identity is observable (`===`, `!==`, command values) reads convert, along with every local whose + * value flows there. + */ +const observeLocals = (unit: Unit, expression: Expression): void => { + references(expression, node => { + if (node.kind === "local" && !unit.observed.has(node.local)) { + unit.observed.add(node.local); + for (const source of unit.assignments.get(node.local) ?? []) observeLocals(unit, source); + } + return false; + }); +}; + +/** Marks a value for an observable position. */ +export const observe = (unit: Unit, value: Expression): Expression => { + observeLocals(unit, value); + return { kind: "observe", value }; +}; + +/** Records an assignment, so observing the local later also observes its sources. */ +export const record = (unit: Unit, local: number, value: Expression): void => { + const assigned = unit.assignments.get(local) ?? []; + assigned.push(value); + unit.assignments.set(local, assigned); + if (unit.observed.has(local)) observeLocals(unit, value); +}; + +/** `local = value`, recorded. */ +export const set = (unit: Unit, local: number, value: Expression, declare: "const" | "let" | undefined): Statement => { + record(unit, local, value); + return { kind: "set", local, value, declare }; +}; diff --git a/packages/ecs/src/implementation/compiler/branches.ts b/packages/ecs/src/implementation/compiler/branches.ts new file mode 100644 index 000000000..2995d9245 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/branches.ts @@ -0,0 +1,45 @@ +import type { Node } from 'estree'; +import { set } from './booleans.js'; +import { dataValue, leavesOf, mapLeaves, sameShape, type Data } from './data.js'; +import { fail } from './failure.js'; +import { local, type Expression } from './ir.js'; +import type { Out } from './out.js'; +import type { Scope } from './scope.js'; +import { fresh } from './unit.js'; +import type { Value } from './value.js'; + +const isTrivial = (expression: Expression): boolean => + expression.kind === "local" || expression.kind === "number" || expression.kind === "boolean"; + +/** Declares `let` locals for data's leaves, and returns them as data plus an assignment of other data to them. */ +export const temporaries = (data: Data, scope: Scope, out: Out): { readonly result: Data; readonly assign: (source: Data) => Out } => { + const locals = leavesOf(data).map(() => fresh(scope.unit, "t")); + let index = 0; + const result = mapLeaves(data, () => local(locals[index++])); + out.push({ kind: "declare", locals }); + return { result, assign: source => leavesOf(source).map((leaf, leafIndex) => set(scope.unit, locals[leafIndex], leaf.expression, undefined)) }; +}; + +/** + * The value of choosing between two branches on `test`: each leaf a conditional when neither branch emitted + * statements, else an `if` assigning temporaries. + */ +export const mergeBranches = (node: Node, test: Expression, consequent: Data, consequentOut: Out, alternate: Data, alternateOut: Out, scope: Scope, out: Out): Value => { + const unit = scope.unit; + if (!sameShape(consequent, alternate)) return fail(node, "both branches must have the same shape"); + if (consequentOut.length === 0 && alternateOut.length === 0) { + let condition = test; + if (consequent.kind !== "leaf" && !isTrivial(test)) { + const name = fresh(unit, "test"); + out.push(set(unit, name, test, "const")); + condition = local(name); + } + const alternateLeaves = leavesOf(alternate); + let index = 0; + return dataValue(mapLeaves(consequent, expression => + ({ kind: "conditional", test: condition, consequent: expression, alternate: alternateLeaves[index++].expression }))); + } + const { result, assign } = temporaries(consequent, scope, out); + out.push({ kind: "if", test, consequent: [...consequentOut, ...assign(consequent)], alternate: [...alternateOut, ...assign(alternate)] }); + return dataValue(result); +}; diff --git a/packages/ecs/src/implementation/compiler/calls.ts b/packages/ecs/src/implementation/compiler/calls.ts new file mode 100644 index 000000000..dca73fe7a --- /dev/null +++ b/packages/ecs/src/implementation/compiler/calls.ts @@ -0,0 +1,384 @@ +import type { SimpleCallExpression, Expression as SourceExpression, Node, SpreadElement, Statement as SourceStatement } from 'estree'; +import { mergeBranches } from './branches.js'; +import { bind } from './bind.js'; +import { observe, set } from './booleans.js'; +import { access } from './access.js'; +import { dataValue, leavesOf, mapLeaves, materialize, toData } from './data.js'; +import { fail, within } from './failure.js'; +import { local, references, type Expression, type Statement } from './ir.js'; +import type { Program } from '../world/kernel.js'; +import { laneCount } from '../world/intrinsics.js'; +import type { Out } from './out.js'; +import { frameIn } from './module-value.js'; +import { scalar } from './scalar.js'; +import { childScope, findLocal, rootScope, type Scope } from './scope.js'; +import { fresh, keyIndex, spawnIndex } from './unit.js'; +import { scalarValue, type Value } from './value.js'; + +const argument = (node: SourceExpression | SpreadElement): SourceExpression => + node.type === "SpreadElement" ? fail(node, "spread arguments are not in the kernel subset") : node; + +const scalarArgument = (node: SourceExpression | SpreadElement, scope: Scope, out: Out): Expression => { + const expression = argument(node); + return scalar(scope.unit.expression(expression, scope, out), expression, scope); +}; + +/** + * A command's or spawn's value for the component `name` as data in a temporary, recording a tuple or struct, which only a + * component stored in linear memory takes. + */ +const commandValue = (name: string, node: Node, scope: Scope, out: Out): Expression => { + const data = toData(scope.unit.expression(node, scope, out), node, scope); + if (data.kind !== "leaf") scope.unit.structuredValues.add(name); + return temporary(scope, out, "value", observe(scope.unit, materialize(data))); +}; + +/** A value as a temporary, so command arguments are evaluated in source order before the command starts. */ +const temporary = (scope: Scope, out: Out, hint: string, value: Expression): Expression => { + if (value.kind === "local" || value.kind === "number" || value.kind === "boolean") return value; + const name = fresh(scope.unit, hint); + out.push(set(scope.unit, name, value, "const")); + return local(name); +}; + +/** The identifier a member chain `A.B.C` starts with, or the node itself. */ +const root = (node: Node): Node => node.type === "MemberExpression" ? root(node.object) : node; + +/** + * The keys a command's list of components names, as `Lists.names` reads them: an array literal's identifiers must not be + * the body's locals. + */ +const commandList = (node: Node, call: string, scope: Scope): readonly string[] => { + if (node.type === "ArrayExpression") for (const element of node.elements) if (element !== null && element.type !== "SpreadElement") commandName(element, scope); + const first = root(node); + if (first.type === "Identifier" && findLocal(scope, first.name) !== undefined) return fail(node, `${call}: ${first.name} is a local; commands name lists bound at module scope`); + const names = scope.unit.lists.names(node); + if ("error" in names) return fail(node, `${call}: ${names.error}`); + const twice = names.find((name, k) => names.indexOf(name) < k); + return twice === undefined ? names : fail(node, `${twice} is listed twice`); +}; + +/** A module-scope identifier naming a component in a command. */ +const commandName = (node: Node, scope: Scope): string => + node.type === "Identifier" && findLocal(scope, node.name) === undefined && scope.module.bindings.has(node.name) + ? node.name + : fail(node, "commands name components by identifiers bound at module scope"); + +const none = scalarValue({ kind: "nullish", value: "undefined" }); +const undefinedValue: Expression = { kind: "nullish", value: "undefined" }; + +/** + * A list's values in its order, each given one evaluated into a temporary in source order, and every other component + * of the list `undefined`, which is its start. + */ +const listValues = (list: readonly string[], node: SourceExpression | SpreadElement | undefined, call: Node, scope: Scope, out: Out): readonly Expression[] => { + if (node?.type !== "ObjectExpression") return fail(node ?? call, "command values must be an object literal like { [position]: value }"); + const given = new Map(); + for (const property of node.properties) { + if (property.type === "SpreadElement" || !property.computed) return fail(property, "command value keys must be computed component identifiers like [position]"); + const name = commandName(property.key, scope); + if (!list.includes(name)) return fail(property.key, `${name} isn't one of the command's components`); + given.set(name, commandValue(name, property.value, scope, out)); + } + return list.map(name => given.get(name) ?? undefinedValue); +}; + +/** A list's values as `(key index, value)` statements, as `listValues` gives them. */ +const commandValues = (list: readonly string[], node: SourceExpression | SpreadElement | undefined, call: Node, scope: Scope, out: Out): readonly Statement[] => { + const values = listValues(list, node, call, scope, out); + return list.map((name, k): Statement => ({ kind: "value", key: keyIndex(scope.unit, name), value: values[k] })); +}; + +/** + * A command's second argument as a list of components, or as one key's name: whatever its definition is (`Lists.component`). + * A body's local is one key, which `commandName` rejects. + */ +const componentOf = (node: Node, call: string, scope: Scope): readonly string[] | { readonly key: string } => { + if (node.type === "ArrayExpression") return commandList(node, call, scope); + const first = root(node); + if (first.type !== "Identifier" || findLocal(scope, first.name) !== undefined) return { key: commandName(node, scope) }; + const named = scope.unit.lists.component(node); + switch (named.kind) { + case "list": return commandList(node, call, scope); + case "key": return { key: node.type === "Identifier" ? commandName(node, scope) : named.name }; + case "error": return fail(node, `${call}: ${named.error}`); + } +}; + +/** + * `commands.spawn(components)(values)`: stages a spawn through the list's spawner, giving each of the components its + * value or its start, and reserving the entity. + */ +const spawn = (inner: SimpleCallExpression, node: SimpleCallExpression, scope: Scope, out: Out): Value => { + const [components, ...extra] = inner.arguments; + if (components === undefined || extra.length > 0 || node.arguments.length !== 1) return fail(node, "a spawn is commands.spawn(components)(values)"); + const list = commandList(components, "commands.spawn", scope); + const values = listValues(list, node.arguments[0], node, scope, out); + const result = fresh(scope.unit, "spawned"); + out.push({ kind: "spawn", local: result, list: spawnIndex(scope.unit, list), values }); + return scalarValue(local(result)); +}; + +const command = (name: string, node: SimpleCallExpression, scope: Scope, out: Out): Value => { + const [entity, second, third, ...rest] = node.arguments; + if (entity === undefined) return fail(node, `commands.${name} needs arguments`); + switch (name) { + case "spawn": + return fail(node, "a kernel calls the spawner where it gets it, as commands.spawn(components)(values)"); + case "despawn": + out.push({ kind: "begin", command: 1, entity: temporary(scope, out, "entity", scalarArgument(entity, scope, out)) }); + return none; + case "add": { + if (second === undefined || rest.length > 0) return fail(node, "an add is commands.add(entity, component, value) or commands.add(entity, components, values)"); + const target = temporary(scope, out, "entity", scalarArgument(entity, scope, out)); + const component = componentOf(argument(second), "commands.add", scope); + if (!("key" in component)) { + const values = commandValues(component, third, node, scope, out); + out.push({ kind: "begin", command: 2, entity: target }, ...values); + return none; + } + const key = keyIndex(scope.unit, component.key); + const value = third === undefined ? undefinedValue : commandValue(component.key, argument(third), scope, out); + out.push({ kind: "begin", command: 2, entity: target }, { kind: "value", key, value }); + return none; + } + default: { + if (second === undefined || third !== undefined) return fail(node, "a remove is commands.remove(entity, component) or commands.remove(entity, components)"); + const target = temporary(scope, out, "entity", scalarArgument(entity, scope, out)); + const component = componentOf(argument(second), "commands.remove", scope); + const names = "key" in component ? [component.key] : component; + out.push({ kind: "begin", command: 3, entity: target }, ...names.map((key): Statement => ({ kind: "component", key: keyIndex(scope.unit, key) }))); + return none; + } + } +}; + +/** Whether a statement returns somewhere outside nested functions and loops. */ +const containsReturn = (node: Node): boolean => { + switch (node.type) { + case "ReturnStatement": return true; + case "BlockStatement": return node.body.some(containsReturn); + case "IfStatement": return containsReturn(node.consequent) || (node.alternate !== null && node.alternate !== undefined && containsReturn(node.alternate)); + default: return false; + } +}; + +const statementsOf = (node: SourceStatement): readonly SourceStatement[] => node.type === "BlockStatement" ? node.body : [node]; + +/** Statements to run in order, each run in its scope: the statements of a block, then of the blocks after it. */ +type Segment = { readonly statements: readonly SourceStatement[]; readonly scope: Scope }; + +/** A copy of a scope, so each branch of a split block declares its own locals. */ +const forkScope = (scope: Scope): Scope => ({ ...scope, locals: new Map(scope.locals) }); + +/** + * A helper body's value. A return inside an `if` splits the block: each branch runs its own statements and then + * the rest of the block, and their returned values merge as a conditional would. + */ +const helperBlock = (name: string, segments: readonly Segment[], node: Node, out: Out): Value => { + const [segment, ...after] = segments; + if (segment === undefined) return fail(node, `${name} must end with return `); + const { statements, scope } = segment; + const unit = scope.unit; + for (let index = 0; index < statements.length; index++) { + const statement = statements[index]; + if (statement.type === "ReturnStatement") { + if (!statement.argument) return fail(statement, `${name} must return a value`); + return unit.expression(statement.argument, scope, out); + } + if (statement.type === "IfStatement" && containsReturn(statement)) { + const test = scalar(unit.expression(statement.test, scope, out), statement.test, scope); + const rest = (fork: Scope): Segment[] => [{ statements: statements.slice(index + 1), scope: fork }, ...after.map(next => ({ ...next, scope: forkScope(next.scope) }))]; + const branch = (taken: SourceStatement | null | undefined, branchOut: Out): Value => { + const fork = forkScope(scope); + const own: Segment[] = taken === null || taken === undefined ? [] : [{ statements: statementsOf(taken), scope: childScope(fork) }]; + return helperBlock(name, [...own, ...rest(fork)], statement, branchOut); + }; + const consequentOut: Out = []; + const alternateOut: Out = []; + const consequent = toData(branch(statement.consequent, consequentOut), statement.consequent, scope); + const alternate = toData(branch(statement.alternate, alternateOut), statement, scope); + return mergeBranches(statement, test, consequent, consequentOut, alternate, alternateOut, scope, out); + } + unit.statement(statement, scope, out); + } + return helperBlock(name, after, node, out); +}; + + +/** An argument's lanes as an intrinsic operand of `type`, or null if its shape doesn't match. */ +const operandLanes = (value: Value, type: Program.ValueType, node: SimpleCallExpression, scope: Scope): readonly Expression[] | null => { + const isLeaf = (item: Value): boolean => ["scalar", "dynamic", "found", "component", "snapshot"].includes(item.kind); + const count = laneCount(type); + if (count === 1) return isLeaf(value) ? [scalar(value, node, scope)] : null; + if (value.kind === "tuple") return value.items.length === count && value.items.every(isLeaf) ? value.items.map(item => scalar(item, node, scope)) : null; + if (value.kind === "component" || value.kind === "snapshot" || value.kind === "dynamic") { + return Array.from({ length: count }, (_, index) => scalar(access(value, { kind: "static", name: index }, node, scope, false), node, scope)); + } + return null; +}; + +/** + * A GPU kernel's call to a function that matches a WGSL builtin, as an intrinsic region: its operands, and its inlined + * body, whose result lanes the region names. Each operand lane is read once by each backend where the call is: by the + * builtin, or by the body binding its parameters. Null where the call doesn't fit the declaration, so it is inlined + * as usual. + */ +const intrinsicCall = (helper: Extract, intrinsic: Program.Intrinsic, values: readonly Value[], node: SimpleCallExpression, scope: Scope, out: Out, compile: (bind: (index: number) => Value, bodyOut: Out) => Value): Value | null => { + const unit = scope.unit; + if (intrinsic.parameters.length !== helper.params.length || values.length !== helper.params.length) return null; + const lanes = intrinsic.parameters.map((type, index) => operandLanes(values[index], type, node, scope)); + if (lanes.some(item => item === null)) return null; + const operands = lanes.map(item => item ?? []); + const body: Out = []; + const result = toData(compile(index => { + const [first, ...rest] = operands[index] ?? []; + return first !== undefined && rest.length === 0 && laneCount(intrinsic.parameters[index] ?? "f32") === 1 + ? scalarValue(first) + : { kind: "tuple", items: (operands[index] ?? []).map(scalarValue) }; + }, body), node, scope); + const leaves = leavesOf(result); + const flat = laneCount(intrinsic.result) === 1 ? result.kind === "leaf" : result.kind === "tuple" && result.items.every(item => item.kind === "leaf"); + if (!flat || leaves.length !== laneCount(intrinsic.result) || leaves.some(leaf => references(leaf.expression, item => item.kind === "column"))) { + out.push(...body); + return dataValue(result); + } + const id = unit.next(); + out.push({ kind: "intrinsic", id, name: helper.name, intrinsic, operands, body }); + let lane = 0; + return dataValue(mapLeaves(result, value => ({ kind: "lane", intrinsic: id, lane: lane++, value }))); +}; + +/** Inlines a helper: arguments are bound to fresh parameter locals, and the body is compiled in the helper's own scope. */ +const inline = (helper: Extract, node: SimpleCallExpression, scope: Scope, out: Out): Value => { + const unit = scope.unit; + if (unit.active.has(helper.identity)) return fail(node, `${helper.name} is recursive`); + if (node.arguments.length > helper.params.length) return fail(node, `${helper.name} takes ${helper.params.length} arguments`); + const values = node.arguments.map(item => unit.expression(argument(item), scope, out)); + if (values.length < helper.params.length) return fail(node, `${helper.name} needs ${helper.params.length} arguments`); + unit.active.add(helper.identity); + const body = (argumentOf: (index: number) => Value, bodyOut: Out): Value => { + const compile = (): Value => { + const inner = rootScope(unit, true, helper.module); + helper.params.forEach((param, index) => bind(param, argumentOf(index), "parameter", inner, bodyOut)); + return helper.body.type === "BlockStatement" + ? helperBlock(helper.name, [{ statements: helper.body.body, scope: inner }], helper.body, bodyOut) + : unit.expression(helper.body, inner, bodyOut); + }; + return helper.module === unit.module ? compile() : within(node, frameIn(helper.module, helper.name), compile); + }; + const argumentOf = (index: number): Value => values[index] ?? fail(node, `${helper.name} needs ${helper.params.length} arguments`); + const result = (unit.target === "gpu" && helper.intrinsic !== undefined ? intrinsicCall(helper, helper.intrinsic, values, node, scope, out, body) : null) + ?? body(argumentOf, out); + unit.active.delete(helper.identity); + return result; +}; + +/** The accumulator local of one leaf of a reduction, made at its first contribution. */ +const accumulator = (scope: Scope, reduced: number, path: readonly (number | string)[]): number => { + const unit = scope.unit; + const existing = unit.accumulators.find(entry => entry.reduced === reduced && JSON.stringify(entry.path) === JSON.stringify(path)); + if (existing !== undefined) return existing.local; + const created = fresh(unit, "reduced"); + unit.accumulators.push({ reduced, path, local: created }); + return created; +}; + +/** `context[resource](value)`: folds each leaf of the value into its accumulator with the resource's operator. */ +const contribute = (reduced: number, node: SimpleCallExpression, scope: Scope, out: Out): Value => { + const { resource, operator } = scope.unit.signature.reduced[reduced] ?? fail(node, "unknown reduction"); + if (node.arguments.length !== 1) return fail(node, `${resource} takes the one value it reduces`); + const value = toData(scope.unit.expression(argument(node.arguments[0]), scope, out), node, scope); + scope.unit.reducedShapes.add(`{ component: ${resource}, leaves: ${JSON.stringify(leavesOf(value).map(leaf => leaf.path))} }`); + for (const { path, expression } of leavesOf(value)) { + const target = accumulator(scope, reduced, path); + const folded: Expression = operator === "min" || operator === "max" + ? { kind: "math", name: operator, arguments: [local(target), expression] } + : { kind: "binary", operator, left: local(target), right: expression }; + out.push(set(scope.unit, target, folded, undefined)); + } + return none; +}; + +const comparisonOperators: ReadonlySet = new Set(["==", "!=", "<", "<=", ">", ">="]); + +/** An object literal whose every key is a comparison's operator, so it's a comparison rather than a struct key part. */ +const comparisonOf = (node: SourceExpression): readonly (readonly [string, SourceExpression])[] | null => { + if (node.type !== "ObjectExpression" || node.properties.length === 0) return null; + const operators: [string, SourceExpression][] = []; + for (const property of node.properties) { + if (property.type !== "Property" || property.computed || property.kind !== "init" || property.method) return null; + const name = property.key.type === "Literal" && typeof property.key.value === "string" ? property.key.value : property.key.type === "Identifier" ? property.key.name : null; + if (name === null || !comparisonOperators.has(name) || property.value.type === "ObjectPattern" || property.value.type === "ArrayPattern" + || property.value.type === "AssignmentPattern" || property.value.type === "RestElement") return null; + operators.push([name, property.value]); + } + return operators; +}; + +/** + * A call of a lookup through an index: each argument a key part's value, given by its scalar leaves, and the last + * possibly a comparison literal. The call site gets its own list and first row, which the world binds to the index. + */ +const probe = (lookup: number, node: SimpleCallExpression, scope: Scope, out: Out): Value => { + const unit = scope.unit; + const values: Expression[] = []; + const leavesIn = (source: SourceExpression): string => { + const leaves = leavesOf(toData(unit.expression(source, scope, out), source, scope)); + for (const leaf of leaves) values.push(temporary(scope, out, "key", observe(unit, leaf.expression))); + return JSON.stringify(leaves.map(leaf => leaf.path)); + }; + const parts = node.arguments.map((item, index) => { + const source = argument(item); + const comparison = comparisonOf(source); + if (comparison === null) return `{ kind: "value", paths: ${leavesIn(source)} }`; + if (index !== node.arguments.length - 1) return fail(source, "only a lookup's last key part may be a comparison"); + return `{ kind: "comparison", operators: [${comparison.map(([operator, operand]) => `{ operator: ${JSON.stringify(operator)}, paths: ${leavesIn(operand)} }`).join(", ")}] }`; + }); + const site = unit.probes.length; + unit.probes.push(`{ lookup: ${lookup}, arguments: [${parts.join(", ")}] }`); + const list = fresh(unit, "list"); + const first = fresh(unit, "first"); + unit.handles.add(list); + unit.handles.add(first); + out.push({ kind: "probe", list, first, lookup, probe: site, values }); + return { kind: "indexed", lookup, list, first }; +}; + +/** Compiles a call: a `Math` function, a command, a lookup, a reduction or a helper. */ +export const call = (node: SimpleCallExpression, scope: Scope, out: Out): Value => { + if (node.optional) return fail(node, "optional calls are not in the kernel subset"); + if (node.callee.type === "Super") return fail(node, "super is not in the kernel subset"); + if (node.callee.type === "CallExpression" && !node.callee.optional && node.callee.callee.type !== "Super") { + const maker = scope.unit.expression(node.callee.callee, scope, out); + if (maker.kind === "method" && maker.owner === "commands" && maker.name === "spawn") return spawn(node.callee, node, scope, out); + } + const callee = scope.unit.expression(node.callee, scope, out); + switch (callee.kind) { + case "method": + if (callee.owner === "commands") return command(callee.name, node, scope, out); + return scalarValue({ kind: "math", name: callee.name, arguments: node.arguments.map(item => scalarArgument(item, scope, out)) }); + case "lookup": { + if (scope.unit.signature.lookups[callee.lookup]?.index === true) return probe(callee.lookup, node, scope, out); + if (node.arguments.length !== 1) return fail(node, "a lookup takes one entity"); + const entity = scalarArgument(node.arguments[0], scope, out); + const found = fresh(scope.unit, "found"); + out.push({ kind: "lookup", local: found, lookup: callee.lookup, entity }); + return { kind: "found", lookup: callee.lookup, expression: local(found) }; + } + case "listAt": { + if (node.arguments.length !== 1) return fail(node, "a list's at takes a row's position"); + const index = scalarArgument(node.arguments[0], scope, out); + const row = fresh(scope.unit, "row"); + scope.unit.handles.add(row); + out.push({ kind: "listRow", row, list: callee.list, lookup: callee.lookup, index }); + return { kind: "indexRow", lookup: callee.lookup, row }; + } + case "function": + return inline(callee, node, scope, out); + case "reducer": + return contribute(callee.reduced, node, scope, out); + default: + return fail(node.callee, "only Math functions, commands, lookups, reductions and helpers can be called"); + } +}; diff --git a/packages/ecs/src/implementation/compiler/columns.ts b/packages/ecs/src/implementation/compiler/columns.ts new file mode 100644 index 000000000..702905a9b --- /dev/null +++ b/packages/ecs/src/implementation/compiler/columns.ts @@ -0,0 +1,46 @@ +import type { Path } from '../world/kernel.js'; +import type { Expression } from './ir.js'; +import type { Unit } from './unit.js'; + +const column = (unit: Unit, identity: string, description: string): number => { + const existing = unit.columnIndex.get(identity); + if (existing !== undefined) return existing; + unit.columns.push(description); + unit.columnIndex.set(identity, unit.columns.length - 1); + return unit.columns.length - 1; +}; + +/** The column of a scalar leaf of a component. */ +export const fieldColumn = (unit: Unit, component: string, path: Path): number => { + const json = JSON.stringify(path); + return column(unit, `field ${component} ${json}`, `{ kind: "field", component: ${component}, path: ${json} }`); +}; + +export const fieldRead = (unit: Unit, component: string, path: Path): Expression => ({ kind: "column", column: fieldColumn(unit, component, path) }); + +/** A component's changed column, immediately followed by its dense chunk column. */ +export const changedColumns = (unit: Unit, component: string): { readonly column: number; readonly chunks: number } => { + const changed = column(unit, `changed ${component}`, `{ kind: "changed", component: ${component} }`); + const chunks = column(unit, `chunks ${component}`, `{ kind: "chunks", component: ${component} }`); + unit.chunks.add(chunks); + return { column: changed, chunks }; +}; + +/** A scalar leaf of a component of the row's parent, read through `up`. */ +export const upFieldRead = (unit: Unit, component: string, path: Path): Expression => { + const json = JSON.stringify(path); + const identity = `${component} ${json}`; + let column = unit.upColumnIndex.get(identity); + if (column === undefined) { + unit.upColumns.push(`{ kind: "field", component: ${component}, path: ${json} }`); + column = unit.upColumns.length - 1; + unit.upColumnIndex.set(identity, column); + } + return { kind: "upColumn", column }; +}; + +/** A component leaf read, of the row or, through `up`, of its parent. */ +export const componentRead = (unit: Unit, value: { readonly component: string; readonly path: Path; readonly up?: true }, path: Path = []): Expression => + value.up === true ? upFieldRead(unit, value.component, [...value.path, ...path]) : fieldRead(unit, value.component, [...value.path, ...path]); + +export const entityRead = (unit: Unit): Expression => ({ kind: "column", column: column(unit, "entity", `{ kind: "entity" }`) }); diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts new file mode 100644 index 000000000..bd4c651c7 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -0,0 +1,139 @@ +import type { Node, ObjectExpression } from 'estree'; +import { bind } from './bind.js'; +import { requireExact } from './exact.js'; +import { expression } from './expression.js'; +import { fail } from './failure.js'; +import type { Link } from './link.js'; +import { createLists } from './lists.js'; +import type { Module } from './module-scope.js'; +import { resolve, type Out } from './out.js'; +import { localName, printStatements } from './print.js'; +import { toProgram } from './program.js'; +import { serialize } from './serialize.js'; +import { unconditionalChunks } from './stamps.js'; +import { chunkShift, type Program } from '../world/kernel.js'; +import { rootScope } from './scope.js'; +import { signature } from './signature.js'; +import { rowLabel, statement } from './statement.js'; +import { createUnit } from './unit.js'; +import type { Value } from './value.js'; + +const parameterValues: readonly Value[] = [{ kind: "row" }, { kind: "context" }]; + +/** + * One compiled kernel: `description(url)` is the source of its `KernelDescription`, with keys as the declaration's + * identifiers and `program` lowered from the same statements as the JS; `create` is the source of + * `KernelModule.create`, which references no module bindings and uses `keys[k]` only as opaque property keys. + */ +export type CompiledKernel = { readonly description: (url: string) => string; readonly create: string }; + +/** + * Statements a GPU kernel can't contain: WGSL has no labeled multi-level branches, and loops aren't supported yet. + * Intrinsic regions emit their builtin, not their body. + */ +const loops = (statements: readonly Program.Statement[]): boolean => statements.some(statement => { + switch (statement.kind) { + case "block": case "loop": case "branch": case "branchIf": case "find": return true; + case "if": return loops(statement.consequent) || loops(statement.alternate); + default: return false; + } +}); + +/** `target` "gpu" also checks, before anything runs, what only a GPU kernel can't do; schemas are checked at registration. */ +export const compileKernel = (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu" = "cpu", link: Link | null = null): CompiledKernel => { + if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") { + return fail(body, "a kernel body must be an inline function"); + } + if (body.async || body.generator) return fail(body, "a kernel body can't be async or a generator"); + if (body.params.length > 2) return fail(body, "a kernel body takes row and context"); + const lists = createLists(module, link, [declaration, body]); + const unit = createUnit(signature(declaration, module.bindings, lists), module, lists, expression, statement, link, target); + const scope = rootScope(unit, false); + const prelude: Out = []; + const rows: Out = []; + body.params.forEach((param, index) => bind(param, parameterValues[index] ?? { kind: "row" }, "const", scope, index === 0 ? rows : prelude)); + if (body.body.type === "BlockStatement") { + for (const item of body.body.body) statement(item, scope, rows); + } else { + statement({ type: "ExpressionStatement", expression: body.body, start: body.body.start, end: body.body.end }, scope, rows); + } + const statements = [...resolve(prelude), ...resolve(rows)]; + for (const chunks of unconditionalChunks(resolve(rows))) unit.stampedPerCall.add(chunks); + const kernel = printStatements(unit, resolve(rows)); + const preludeCode = printStatements(unit, resolve(prelude)); + const accumulators = unit.accumulators.map(entry => entry.local); + const { signature: { resources, lookups, up, ordered, filtered, reduced }, columns, booleans, keys } = unit; + /** Whether it runs a list of rows, which needn't be consecutive: `up`'s depth order, or an index's. */ + const listed = up !== null || ordered; + const program = toProgram(statements, target === "gpu" ? local => localName(unit, local) : undefined, accumulators, unit.up, listed); + if (target === "gpu") { + if (lookups.length > 0) fail(declaration, "a GPU kernel can't use lookups yet"); + if (filtered) fail(declaration, "a GPU kernel can't use change filters yet"); + if (reduced.length > 0) fail(declaration, "a GPU kernel can't use reductions yet"); + if (program === null) fail(body, "a GPU kernel can't issue commands or use values that aren't numbers or booleans"); + else if (loops(program.body)) fail(body, "a GPU kernel can't use loops or labeled blocks yet"); + } + const contributed = new Set(unit.accumulators.flatMap(entry => reduced[entry.reduced]?.resource ?? [])); + requireExact(declaration, { query: unit.used, resources: { read: unit.used.resources, write: new Set(), reduce: contributed }, lookups: unit.used.lookups, up: unit.used.up }, module.bindings, lists); + const strided = columns.flatMap((_, index) => unit.chunks.has(index) ? [] : [index]); + const locals = [ + ...columns.map((_, index) => `const c${index} = columns[${index}];`), + ...resources.map((_, index) => `const r${index} = resources[${index}];`), + ...lookups.map((_, index) => `const l${index} = lookups[${index}];`), + ...unit.spawns.map((_, index) => `const spawn${index} = commands.spawners[${index}];`), + // A call's rows share one parent archetype, whose columns `up` reads at each row's parent row `p`. + ...unit.up === null ? [] : [ + `const ${localName(unit, unit.up)} = found ? true : null;`, + ...unit.upColumns.map((_, index) => `const u${index} = upColumns[${index}];`), + ], + ]; + return { + description: url => [ + "{", + `columns: [${columns.join(", ")}],`, + `shapes: [${[...unit.shapes].join(", ")}],`, + `resources: [${resources.join(", ")}],`, + `lookups: [${lookups.map(lookup => JSON.stringify(lookup.name)).join(", ")}],`, + ...unit.probes.length === 0 ? [] : [`probes: [${unit.probes.join(", ")}],`, `rowLeaves: [${unit.rowLeaves.join(", ")}],`], + ...up === null ? [] : ["up: true,", `upColumns: [${unit.upColumns.join(", ")}],`], + ...ordered ? ["ordered: true,"] : [], + `keys: [${keys.join(", ")}],`, + `spawns: ${JSON.stringify(unit.spawns)},`, + `structuredValues: ${JSON.stringify([...unit.structuredValues].map(name => unit.keys.indexOf(name)).sort((a, b) => a - b))},`, + `url: ${url},`, + `program: ${serialize(program)},`, + `reducedShapes: [${[...unit.reducedShapes].join(", ")}],`, + `reductions: [${unit.accumulators.map(entry => { + const { resource, operator } = reduced[entry.reduced] ?? fail(declaration, "unknown reduction"); + return `{ resource: ${resource}, operator: ${JSON.stringify(operator)}, path: ${JSON.stringify(entry.path)} }`; + }).join(", ")}],`, + "}", + ].join("\n"), + // Per-registration constants live in the closure. Each column's element index `i * s + o` is strength-reduced to + // an induction variable in the loop update, which `continue rows` still runs. + create: [ + "(booleans, strides, offsets, upBooleans, upStrides, upOffsets) => {", + ...[...booleans].map(index => `const b${index} = booleans[${index}];`), + ...[...unit.upBooleans].map(index => `const ub${index} = upBooleans[${index}];`), + ...unit.upColumns.map((_, index) => `const us${index} = upStrides[${index}], uo${index} = upOffsets[${index}];`), + ...strided.map(index => `const s${index} = strides[${index}], o${index} = offsets[${index}];`), + // Each lookup through an index's site holds its list and rows in handles its reader fills, made once. + ...[...unit.handles].map(handle => `const ${localName(unit, handle)} = { a: 0, b: 0, c: 0, d: 0, e: 0, f: 0, g: 0, h: 0 };`), + "return (columns, start, end, resources, lookups, keys, args, commands, tick, partials, partial, rows, parents, upColumns, found) => {", + ...locals, + preludeCode, + ...accumulators.map((local, k) => `let ${localName(unit, local)} = partials[partial + ${k}];`), + // A kernel that runs a list of rows, which needn't be consecutive, stamps each row's own chunk. + ...listed ? [] : [...unit.stampedPerCall].map(chunks => + `if (start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`), + listed + ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(chunks => `c${chunks}[i >> ${chunkShift}] = tick;`)].join(" ")}` + : `${rowLabel}: for (let ${["i = start", ...strided.map(index => `x${index} = start * s${index} + o${index}`)].join(", ")}; i < end; ${["i++", ...strided.map(index => `x${index} += s${index}`)].join(", ")}) {`, + kernel, + "}", + ...accumulators.map((local, k) => `partials[partial + ${k}] = ${localName(unit, local)};`), + "};", + "}", + ].filter(line => line !== "").join("\n"), + }; +}; diff --git a/packages/ecs/src/implementation/compiler/data.ts b/packages/ecs/src/implementation/compiler/data.ts new file mode 100644 index 000000000..9756468eb --- /dev/null +++ b/packages/ecs/src/implementation/compiler/data.ts @@ -0,0 +1,94 @@ +import type { Node } from 'estree'; +import type { Path } from '../world/kernel.js'; +import { fail } from './failure.js'; +import type { Expression } from './ir.js'; +import { scalar } from './scalar.js'; +import type { Scope } from './scope.js'; +import { local } from './ir.js'; +import { snapshotLeaf, type Snapshot } from './snapshot.js'; +import type { Value } from './value.js'; + +/** A value with a statically known shape: scalar leaves as expressions. */ +export type Data = + | { readonly kind: "leaf"; readonly expression: Expression } + | { readonly kind: "tuple"; readonly items: readonly Data[] } + | { readonly kind: "struct"; readonly fields: readonly (readonly [string, Data])[] }; + +/** + * A snapshot's value at `path`, shaped by the leaves below it the body has used or assigned: a tuple up to the highest + * element used, or a struct of the fields used. With none, it is one leaf. + */ +const snapshotData = (unit: Scope["unit"], snapshot: Snapshot, path: Path): Data => { + const below = [...snapshot.leaves.keys()].flatMap((identity): Path[] => { + const leafPath: unknown = JSON.parse(identity); + if (!Array.isArray(leafPath) || leafPath.length <= path.length || !path.every((item, index) => leafPath[index] === item)) return []; + return [leafPath.filter((item): item is number | string => typeof item === "number" || typeof item === "string")]; + }); + const next = below.map(item => item[path.length]); + if (next.length === 0) return { kind: "leaf", expression: local(snapshotLeaf(unit, snapshot, path)) }; + if (next.every(item => typeof item === "number")) { + const length = Math.max(...next.filter((item): item is number => typeof item === "number")) + 1; + return { kind: "tuple", items: Array.from({ length }, (_, index) => snapshotData(unit, snapshot, [...path, index])) }; + } + const names = [...new Set(next.filter((item): item is string => typeof item === "string"))]; + return { kind: "struct", fields: names.map(name => [name, snapshotData(unit, snapshot, [...path, name])]) }; +}; + +/** Converts a value to data. Values of unknown shape become one leaf; a snapshot takes the shape its leaves show. */ +export const toData = (value: Value, node: Node, scope: Scope): Data => { + switch (value.kind) { + case "tuple": return { kind: "tuple", items: value.items.map(item => toData(item, node, scope)) }; + case "struct": return { kind: "struct", fields: value.fields.map(([name, field]) => [name, toData(field, node, scope)]) }; + case "snapshot": return snapshotData(scope.unit, value.snapshot, value.path); + default: return { kind: "leaf", expression: scalar(value, node, scope) }; + } +}; + +export type Leaf = { readonly path: Path; readonly expression: Expression }; + +export const leavesOf = (data: Data, prefix: Path = []): readonly Leaf[] => { + switch (data.kind) { + case "leaf": return [{ path: prefix, expression: data.expression }]; + case "tuple": return data.items.flatMap((item, index) => leavesOf(item, [...prefix, index])); + case "struct": return data.fields.flatMap(([name, field]) => leavesOf(field, [...prefix, name])); + } +}; + +export const sameShape = (a: Data, b: Data): boolean => { + switch (a.kind) { + case "leaf": return b.kind === "leaf"; + case "tuple": return b.kind === "tuple" && a.items.length === b.items.length && a.items.every((item, index) => sameShape(item, b.items[index])); + case "struct": return b.kind === "struct" && a.fields.length === b.fields.length + && a.fields.every(([name, field], index) => b.fields[index][0] === name && sameShape(field, b.fields[index][1])); + } +}; + +/** Rebuilds data with each leaf replaced. */ +export const mapLeaves = (data: Data, map: (expression: Expression, path: Path) => Expression, prefix: Path = []): Data => { + switch (data.kind) { + case "leaf": return { kind: "leaf", expression: map(data.expression, prefix) }; + case "tuple": return { kind: "tuple", items: data.items.map((item, index) => mapLeaves(item, map, [...prefix, index])) }; + case "struct": return { kind: "struct", fields: data.fields.map(([name, field]) => [name, mapLeaves(field, map, [...prefix, name])]) }; + } +}; + +export const dataValue = (data: Data): Value => { + switch (data.kind) { + case "leaf": return { kind: "scalar", expression: data.expression }; + case "tuple": return { kind: "tuple", items: data.items.map(dataValue) }; + case "struct": return { kind: "struct", fields: data.fields.map(([name, field]) => [name, dataValue(field)]) }; + } +}; + +/** Data as a JS value, for command arguments. */ +export const materialize = (data: Data): Expression => { + switch (data.kind) { + case "leaf": return data.expression; + case "tuple": return { kind: "array", items: data.items.map(materialize) }; + case "struct": return { kind: "record", fields: data.fields.map(([name, field]) => [name, materialize(field)]) }; + } +}; + +export const requireSameShape = (node: Node, a: Data, b: Data): void => { + if (!sameShape(a, b)) fail(node, "both values must have the same shape"); +}; diff --git a/packages/ecs/src/implementation/compiler/estree.ts b/packages/ecs/src/implementation/compiler/estree.ts new file mode 100644 index 000000000..cb9ab96b5 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/estree.ts @@ -0,0 +1,63 @@ +import type { Node, Pattern, Program } from 'estree'; +import type { parseAst } from 'vite'; + +declare module 'estree' { + interface BaseNode { + /** Source offsets; the bundler's parser sets them on every node. */ + start: number; + end: number; + } +} + +/** A program from the bundler's parser, as the ESTree program the compiler reads. */ +export const estreeOf = (program: ReturnType): Program => + // The bundler's parser emits ESTree for JavaScript; its own AST types model some nodes differently, such as a directive's literal. + program as Program; + +export const isNode = (value: unknown): value is Node => + typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; + +/** The direct child nodes of `node`, in source order. */ +export const children = (node: Node): readonly Node[] => { + const result: Node[] = []; + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) { + if (isNode(item)) result.push(item); + } + } else if (isNode(value)) { + result.push(value); + } + } + return result; +}; + +export const isFunction = (node: Node): boolean => + node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration"; + +export const patternNames = (pattern: Pattern): readonly string[] => { + switch (pattern.type) { + case "Identifier": return [pattern.name]; + case "ArrayPattern": return pattern.elements.flatMap(element => element === null ? [] : patternNames(element)); + case "ObjectPattern": return pattern.properties.flatMap(property => patternNames(property.type === "RestElement" ? property.argument : property.value)); + case "RestElement": return patternNames(pattern.argument); + case "AssignmentPattern": return patternNames(pattern.left); + case "MemberExpression": return []; + } +}; + +/** Calls `declare` for each name declared anywhere inside `node`, once per declaration: variables, parameters, functions, classes and catch parameters. */ +export const declaredIn = (node: Node, declare: (name: string) => void): void => { + switch (node.type) { + case "VariableDeclarator": for (const name of patternNames(node.id)) declare(name); break; + case "FunctionDeclaration": case "FunctionExpression": case "ClassDeclaration": case "ClassExpression": + if (node.id) declare(node.id.name); + break; + case "CatchClause": if (node.param) for (const name of patternNames(node.param)) declare(name); break; + default: break; + } + if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration") { + for (const param of node.params) for (const name of patternNames(param)) declare(name); + } + for (const child of children(node)) declaredIn(child, declare); +}; diff --git a/packages/ecs/src/implementation/compiler/exact.ts b/packages/ecs/src/implementation/compiler/exact.ts new file mode 100644 index 000000000..a6005bb68 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/exact.ts @@ -0,0 +1,77 @@ +import type { Expression, ObjectExpression, Pattern } from 'estree'; +import { fail } from './failure.js'; +import type { Lists } from './lists.js'; +import type { ModuleScope } from './module-scope.js'; + +type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet }; + +/** The keys a body reads, writes and contributes to, by module-scope binding name; null where the body doesn't show every use. */ +export type Used = { + readonly query: Access | null; + readonly resources: (Access & { readonly reduce: ReadonlySet }) | null; + /** The components read from each lookup's results, by lookup name: null for one whose results aren't followed. */ + readonly lookups: ReadonlyMap | null> | null; + /** The components read from `up`, when the body's uses are all known. */ + readonly up?: ReadonlySet; +}; + +/** An object literal's plain-named properties; none for anything else. */ +const fields = (node: Expression | Pattern | undefined): ReadonlyMap => new Map(node?.type !== "ObjectExpression" ? [] : + node.properties.flatMap((property): (readonly [string, Expression | Pattern])[] => + property.type === "Property" && !property.computed && property.key.type === "Identifier" ? [[property.key.name, property.value]] : [])); + +/** + * Requires a list to name only keys in `used`. A list `lists` can't read isn't checked, since its keys can't be matched + * to the body's. + */ +const requireUsed = (node: Expression | Pattern | undefined, used: ReadonlySet, lists: Lists, message: (name: string) => string): void => { + const names = node === undefined ? null : lists.names(node); + if (node === undefined || names === null || "error" in names) return; + // A key of an array literal is reported where it's listed, and one of a list declared elsewhere where the system names the list. + names.forEach((name, k) => { + const element = node.type === "ArrayExpression" ? node.elements[k] : null; + if (!used.has(name)) fail(element ?? node, message(name)); + }); +}; + +/** Requires `reduce` to name only resources in `contributed`, when it names each by a computed module-scope key. */ +const requireContributed = (reduce: Expression | Pattern | undefined, contributed: ReadonlySet, module: ModuleScope): void => { + if (reduce?.type !== "ObjectExpression") return; + const keys = reduce.properties.map(property => + property.type === "Property" && property.computed && property.key.type === "Identifier" && module.has(property.key.name) ? property.key : null); + for (const key of keys) if (key === null) return; + for (const key of keys) if (key !== null && !contributed.has(key.name)) fail(key, `${key.name} is in reduce but never contributed to; remove it`); +}; + +/** + * Requires a system's declared `read`, `write` and `reduce` to be exactly what its body uses: a key it never uses + * would still narrow which entities match, and hold back systems that could otherwise run alongside it. + */ +export const requireExact = (declaration: ObjectExpression, used: Used, module: ModuleScope, lists: Lists): void => { + const declared = fields(declaration); + const components = fields(declared.get("components")); + const resources = fields(declared.get("resources")); + if (used.query !== null) { + requireUsed(components.get("read"), used.query.read, lists, name => `${name} is in components read but never read; remove it, or move it to components has to keep requiring it`); + requireUsed(components.get("write"), used.query.write, lists, name => `${name} is in components write but never written; remove it, or move it to components read if it's only read`); + } + if (used.resources !== null) { + requireUsed(resources.get("read"), used.resources.read, lists, name => `${name} is in resources read but never read; remove it`); + requireUsed(resources.get("write"), used.resources.write, lists, name => `${name} is in resources write but never written; remove it, or move it to resources read if it's only read`); + requireContributed(declared.get("reduce"), used.resources.reduce, module); + } + if (used.up !== undefined) { + requireUsed(fields(declared.get("up")).get("read"), used.up, lists, + name => `${name} is in up.read but never read; remove it`); + } + if (used.lookups !== null) { + for (const [lookup, value] of fields(declared.get("lookup"))) { + const read = used.lookups.get(lookup); + if (read === null) continue; + const index = fields(value).has("index"); + requireUsed(fields(value).get("read"), read ?? new Set(), lists, name => index + ? `${name} is in lookup.${lookup} read but never read; remove it` + : `${name} is in lookup.${lookup} read but never read; remove it, or move it to lookup.${lookup} has to keep requiring it`); + } + } +}; diff --git a/packages/ecs/src/implementation/compiler/expression.ts b/packages/ecs/src/implementation/compiler/expression.ts new file mode 100644 index 000000000..0e30a6221 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/expression.ts @@ -0,0 +1,124 @@ +import type { ConditionalExpression, Identifier, LogicalExpression, Node } from 'estree'; +import { access } from './access.js'; +import { observe, set } from './booleans.js'; +import { mergeBranches } from './branches.js'; +import { call } from './calls.js'; +import { dataValue, leavesOf, mapLeaves, sameShape, toData, type Data } from './data.js'; +import { fail } from './failure.js'; +import { isBinary, isUnary, local, not, number, type Expression } from './ir.js'; +import { memberKey } from './key.js'; +import { moduleValue } from './module-value.js'; +import type { Out } from './out.js'; +import { scalar } from './scalar.js'; +import { findLocal, rootScope, type Scope } from './scope.js'; +import { fresh } from './unit.js'; +import { scalarValue, type Value } from './value.js'; + +const isTrivial = (expression: Expression): boolean => + expression.kind === "local" || expression.kind === "number" || expression.kind === "boolean"; + +const identifier = (node: Identifier, scope: Scope, out: Out): Value => { + const local = findLocal(scope, node.name); + if (local !== undefined) return local.value; + const binding = scope.module.bindings.get(node.name); + if (binding !== undefined) return moduleValue(node, node.name, binding, scope.module, scope, out); + switch (node.name) { + case "Math": return { kind: "math" }; + case "Infinity": return scalarValue(number(Infinity)); + case "NaN": return scalarValue(number(NaN)); + case "undefined": return scalarValue({ kind: "nullish", value: "undefined" }); + default: return fail(node, `${node.name} is not defined in the kernel`); + } +}; + +/** `test ? a : b` over any matching shapes. Branches that emit statements become an `if`. */ +const conditional = (node: ConditionalExpression, scope: Scope, out: Out): Value => { + const unit = scope.unit; + const test = scalar(unit.expression(node.test, scope, out), node.test, scope); + const consequentOut: Out = []; + const alternateOut: Out = []; + const consequent = toData(unit.expression(node.consequent, scope, consequentOut), node.consequent, scope); + const alternate = toData(unit.expression(node.alternate, scope, alternateOut), node.alternate, scope); + return mergeBranches(node, test, consequent, consequentOut, alternate, alternateOut, scope, out); +}; + +/** `a && b`, `a || b`, `a ?? b`. A right side that emits statements runs only when needed. */ +const logical = (node: LogicalExpression, scope: Scope, out: Out): Value => { + const unit = scope.unit; + const left = scalar(unit.expression(node.left, scope, out), node.left, scope); + const rightOut: Out = []; + const right = scalar(unit.expression(node.right, scope, rightOut), node.right, scope); + if (rightOut.length === 0) return scalarValue({ kind: "logical", operator: node.operator, left, right }); + const name = fresh(unit, "t"); + const current = local(name); + const condition: Expression = node.operator === "&&" ? current : node.operator === "||" ? not(current) + : { kind: "binary", operator: "==", left: current, right: { kind: "nullish", value: "null" } }; + out.push(set(unit, name, left, "let"), { kind: "if", test: condition, consequent: [...rightOut, set(unit, name, right, undefined)], alternate: [] }); + return scalarValue(current); +}; + +const literal = (node: Extract): Value => { + if (typeof node.value === "number") return scalarValue(number(node.value)); + if (typeof node.value === "boolean") return scalarValue({ kind: "boolean", value: node.value }); + if (node.value === null) return scalarValue({ kind: "nullish", value: "null" }); + return fail(node, "only number, boolean and null literals are in the kernel subset"); +}; + +/** Compiles an expression to a value, emitting any statements it needs into `out`. */ +export const expression = (node: Node, scope: Scope, out: Out): Value => { + switch (node.type) { + case "Literal": + return literal(node); + case "Identifier": + return identifier(node, scope, out); + case "ArrayExpression": + return { + kind: "tuple", items: node.elements.map(element => element === null ? fail(node, "holes are not in the kernel subset") + : element.type === "SpreadElement" ? fail(element, "spread is not in the kernel subset") + : expression(element, scope, out)), + }; + case "ObjectExpression": + return { + kind: "struct", fields: node.properties.map(property => { + if (property.type === "SpreadElement") return fail(property, "spread is not in the kernel subset"); + if (property.kind !== "init" || property.method || property.computed) return fail(property, "struct literals use plain field names"); + const name = property.key.type === "Identifier" ? property.key.name + : property.key.type === "Literal" && typeof property.key.value === "string" ? property.key.value + : fail(property.key, "struct literals use plain field names"); + const value = expression(property.value, scope, out); + return [name, value] satisfies readonly [string, Value]; + }), + }; + case "UnaryExpression": + if (!isUnary(node.operator)) return fail(node, `${node.operator} is not in the kernel subset`); + return scalarValue({ kind: "unary", operator: node.operator, operand: scalar(expression(node.argument, scope, out), node.argument, scope) }); + case "BinaryExpression": { + const operator = node.operator; + if (!isBinary(operator) || node.left.type === "PrivateIdentifier") return fail(node, `${node.operator} is not in the kernel subset`); + const left = scalar(expression(node.left, scope, out), node.left, scope); + const right = scalar(expression(node.right, scope, out), node.right, scope); + // Strict equality tells true from 1, so boolean reads are converted here. + const strict = operator === "===" || operator === "!=="; + return scalarValue(strict + ? { kind: "binary", operator, left: observe(scope.unit, left), right: observe(scope.unit, right) } + : { kind: "binary", operator, left, right }); + } + case "LogicalExpression": + return logical(node, scope, out); + case "ConditionalExpression": + return conditional(node, scope, out); + case "MemberExpression": + if (node.object.type === "Super") return fail(node, "super is not in the kernel subset"); + return access(expression(node.object, scope, out), memberKey(node, scope, out), node, scope, node.optional); + case "ChainExpression": + return expression(node.expression, scope, out); + case "CallExpression": + return call(node, scope, out); + case "AssignmentExpression": case "UpdateExpression": + return fail(node, "assignments must be statements in a kernel"); + case "ArrowFunctionExpression": case "FunctionExpression": + return fail(node, "functions can't be created in a kernel; define helpers at module scope"); + default: + return fail(node, `${node.type} is not in the kernel subset`); + } +}; diff --git a/packages/ecs/src/implementation/compiler/failure.ts b/packages/ecs/src/implementation/compiler/failure.ts new file mode 100644 index 000000000..aa7cedfd0 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/failure.ts @@ -0,0 +1,37 @@ +/** Where an inlined function failed: the function, and the failing line in its file. */ +export type Frame = { readonly name: string; readonly file: string; readonly line: number; readonly column: number }; + +/** + * A build error at a source offset in the module being transformed. When it is inside functions inlined from other + * modules, `position` is the call in that module and `frames` lead to the failure, innermost first. + */ +export type Failure = { readonly message: string; readonly position: number; readonly frames: readonly Frame[] }; + +const failures = new WeakMap(); + +const abort = (failure: Failure): never => { + const error = new Error(failure.message); + failures.set(error, failure); + throw error; +}; + +/** Aborts compiling the current module; `transformKernels` returns it as a `Failure`. */ +export const fail = (node: { readonly start: number }, message: string): never => abort({ message, position: node.start, frames: [] }); + +export const failureOf = (error: unknown): Failure | undefined => + typeof error === "object" && error !== null ? failures.get(error) : undefined; + +/** Runs `compile`; a failure inside it moves to `node`, recording `frame` for where it was. */ +export const within = (node: { readonly start: number }, frame: (position: number) => Frame, compile: () => T): T => { + try { + return compile(); + } catch (error) { + const failure = failureOf(error); + if (failure === undefined) throw error; + return abort({ message: failure.message, position: node.start, frames: [...failure.frames, frame(failure.position)] }); + } +}; + +/** A failure's message followed by its frames, as a stack trace shows them. */ +export const describeFailure = (failure: Failure): string => + [failure.message, ...failure.frames.map(frame => ` at ${frame.name} (${frame.file}:${frame.line}:${frame.column})`)].join("\n"); diff --git a/packages/ecs/src/implementation/compiler/hygiene.test.ts b/packages/ecs/src/implementation/compiler/hygiene.test.ts new file mode 100644 index 000000000..efe586ae3 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/hygiene.test.ts @@ -0,0 +1,73 @@ +import { expect, it } from "vitest"; +import { Component, System } from "../../index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld } from "../index.js"; + +// Keys named as the generated spawn and add functions name their own bindings: none may capture them. +const p = Symbol("p"); +const c = Symbol("c"); +const l = Symbol("l"); +const s = Symbol("s"); +const e = Symbol("e"); +const v0 = Symbol("v0"); +const v1 = Symbol("v1"); + +declare module "@adobe/ecs" { + interface Components { + [p]: { type: number }; + [c]: { type: number }; + [l]: { type: number }; + [s]: { type: number }; + [e]: { type: number }; + [v0]: { type: number }; + [v1]: { type: number }; + } +} + +const number = { type: { type: "number" } } as const; +Component.register(p, number); +Component.register(c, number); +Component.register(l, number); +Component.register(s, number); +Component.register(e, number); +Component.register(v0, number); +Component.register(v1, number); + +const spawn = System.create(({ commands }) => { + const spawned = commands.spawn([p, c, l, s])({ [p]: 1, [c]: 2, [l]: 3, [s]: 4 }); + commands.add(spawned, [e, v0, v1], { [e]: 5, [v0]: 6, [v1]: 7 }); + const added = commands.spawn([c, l, s, e, v0, v1])({ [c]: 12, [l]: 13, [s]: 14, [e]: 15, [v0]: 16, [v1]: 17 }); + commands.add(added, [p], { [p]: 11 }); +}); + +const read: number[][] = []; +const readAll = System.create({ observer: true, components: { read: [p, c, l, s, e, v0, v1] } }, row => { + read.push([row[p], row[c], row[l], row[s], row[e], row[v0], row[v1]]); +}); + +// A kernel whose module constant and locals are named as the kernel emitter's bindings. +const i = 100; +const grow = System.create({ kernel: "cpu", components: { read: [c, l], write: [p, s] } }, row => { + const n = row[c]; + const x0 = row[l]; + const s0 = n + x0; + row[p] = s0 + i; + row[s] = row[p] - n; +}); + +it("runs a kernel whose names are the kernel emitter's", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(grow); + read.length = 0; + await world.run(readAll); + expect(read.sort((a, b) => (a[0] ?? 0) - (b[0] ?? 0))).toEqual([[105, 2, 3, 103, 5, 6, 7], [125, 12, 13, 113, 15, 16, 17]]); +}); + +it("spawns and adds through keys named as the generated code's bindings", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn); + read.length = 0; + await world.run(readAll); + expect(read.sort((a, b) => (a[0] ?? 0) - (b[0] ?? 0))).toEqual([[1, 2, 3, 4, 5, 6, 7], [11, 12, 13, 14, 15, 16, 17]]); +}); diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts new file mode 100644 index 000000000..a3d7bc206 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from 'vitest'; +import { parseAst } from 'vite'; +import { idOfKey, takeIdOfDeclaration } from '../../interface/registry/ids.js'; +import { estreeOf } from './estree.js'; +import { identify as identifyModule, moduleIdOf } from './identify.js'; +import { attachKernelPath } from './paths.js'; +import { decodeMappings } from './source-map.js'; + +const parse = (code: string) => estreeOf(parseAst(code)); +/** Imports from the package, and from `./system.js` for `System` alone, are the package's namespaces. */ +const resolve = async (source: string, namespace: string) => source === "@adobe/ecs" || (source === "./system.js" && namespace === "System"); +const identify = (code: string) => identifyModule(code, "src/keys.ts", parse, resolve); + +/** The calls the rewrite wraps around each identified `Symbol(...)`, as `[symbol source, id]`. */ +const wrapped = (code: string | undefined): readonly (readonly [string, string])[] => + [...(code ?? "").matchAll(/identify\$\((Symbol\([^)]*\)), "([^"]*)"\)/g)].map(match => [match[1] ?? "", match[2] ?? ""]); + +describe("identify, for symbols", () => { + test("gives each top-level Symbol(...) declaration its module's id and binding", async () => { + const result = await identify([ + `const a = Symbol("a");`, + `let b = Symbol(), c = 1, d = Symbol("d");`, + `var e = Symbol("e");`, + `export const f = Symbol("f"), g = Symbol();`, + `export default Symbol("h");`, + ].join("\n")); + expect(wrapped(result?.code)).toEqual([ + [`Symbol("a")`, "src/keys.ts#a"], [`Symbol()`, "src/keys.ts#b"], [`Symbol("d")`, "src/keys.ts#d"], [`Symbol("e")`, "src/keys.ts#e"], + [`Symbol("f")`, "src/keys.ts#f"], [`Symbol()`, "src/keys.ts#g"], [`Symbol("h")`, "src/keys.ts#default"], + ]); + expect(result?.code).toContain("c = 1"); + }); + test("records each id where the registry reads it, returning the same symbol", async () => { + const result = await identify(`const a = Symbol("a");\nlet b = Symbol();`); + const [a, b]: unknown[] = new Function(`${result?.code}\nreturn [a, b];`)(); + expect([typeof a === "symbol" ? idOfKey(a) : a, typeof b === "symbol" ? idOfKey(b) : b]).toEqual(["src/keys.ts#a", "src/keys.ts#b"]); + expect(typeof a === "symbol" ? a.description : a).toBe("a"); + }); + test("leaves symbols made in functions, blocks and other expressions", async () => { + expect(await identify([ + `function make() { const inner = Symbol("inner"); return inner; }`, + `const arrow = () => Symbol("arrow");`, + `{ const block = Symbol("block"); }`, + `if (true) var conditional = Symbol("conditional");`, + `const [first] = [Symbol("first")];`, + `const shared = Symbol.for("shared");`, + `const wrapped = Object(Symbol("wrapped"));`, + `export const later = make();`, + ].join("\n"))).toBe(null); + }); + test("leaves a module where Symbol isn't the global", async () => { + for (const shadow of [`import { Symbol } from "elsewhere";`, `const Symbol = (name) => name;`, `function Symbol() {}`, `class Symbol {}`, `var Symbol;`, `import Symbol from "elsewhere";`, `import * as Symbol from "elsewhere";`]) { + expect(await identify(`${shadow}\nconst a = Symbol("a");`)).toBe(null); + } + expect(await identify(`const globalThis = {};\nconst a = Symbol("a");`)).toBe(null); + }); + test("doesn't parse code without Symbol(", async () => { + expect(await identifyModule(`const a = 1;`, "src/keys.ts", () => { throw new Error("parsed"); }, resolve)).toBe(null); + }); + test("maps the code it returns back to the source", async () => { + const source = `const a = Symbol("a");\nexport const b = Symbol("b"); const after = 2;`; + const result = await identify(source); + const lines = result?.code.split("\n") ?? []; + const segments = decodeMappings(result?.map.mappings ?? ""); + const at = (line: number, text: string) => { + const column = lines[line]?.indexOf(text) ?? -1; + const segment = [...segments[line] ?? []].reverse().find(candidate => candidate.column <= column); + return segment === undefined ? null : [segment.sourceLine, segment.sourceColumn + column - segment.column]; + }; + expect(at(1, "const after")).toEqual([1, source.split("\n")[1]?.indexOf("const after")]); + expect(at(1, `Symbol("b")`)).toEqual([1, source.split("\n")[1]?.indexOf(`Symbol("b")`)]); + expect(result?.map.sources).toEqual(["src/keys.ts"]); + }); +}); + +/** The declarations the rewrite wraps, as `[declaration source, id]`. */ +const declared = (code: string | undefined): readonly (readonly [string, string])[] => + [...(code ?? "").matchAll(/identify\$\((\{[^}]*\}), "([^"]*)"\)/g)].map(match => [match[1] ?? "", match[2] ?? ""]); + +describe("identify, for systems, rules and finalizers", () => { + test("gives the declaration of each top-level binding's constructor call its module's id and binding", async () => { + const result = await identify([ + `import { System as S, Rule, Finalizer, Index } from "@adobe/ecs";`, + `const move = S.create({ name: "move" }, () => {});`, + `export const grid = Index.create({ name: "grid" });`, + `export const draw = S.render({ name: "draw" }, () => {}), main = S.create({ name: "main", systems: [move] });`, + `const all = S.create([move]);`, + `let shown = Rule.create({ name: "shown" });`, + `var release = Finalizer.create({ name: "release" }, () => {});`, + `export default Finalizer.changes({ name: "save" }, () => {});`, + ].join("\n")); + expect(declared(result?.code)).toEqual([ + [`{ name: "move" }`, "src/keys.ts#move"], [`{ name: "grid" }`, "src/keys.ts#grid"], [`{ name: "draw" }`, "src/keys.ts#draw"], [`{ name: "main", systems: [move] }`, "src/keys.ts#main"], + [`{ name: "shown" }`, "src/keys.ts#shown"], [`{ name: "release" }`, "src/keys.ts#release"], [`{ name: "save" }`, "src/keys.ts#default"], + ]); + // A composite's list of members takes its id as a declaration does. + expect(result?.code).toContain(`identify$([move], "src/keys.ts#all")`); + }); + test("records each id where the registry takes it, passing the same declaration", async () => { + const result = await identify(`import { System } from "@adobe/ecs";\nconst move = System.create({ name: "move" });`); + const evaluated = (result?.code ?? "").replace(/^import .*$/m, "const System = { create: (declaration) => declaration };"); + const declaration: unknown = new Function(`${evaluated}\nreturn move;`)(); + expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "name"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]); + }); + test("identifies a kernel's call the kernel transform wrapped", async () => { + const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(attachKernelPath)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ name: "grow", kernel: "cpu" }, () => {}), {});`); + expect(declared(result?.code)).toEqual([[`{ name: "grow", kernel: "cpu" }`, "src/keys.ts#grow"]]); + }); + test("follows the namespace to the module that exports it, under any local name", async () => { + const result = await identify(`import { System as Systems, Rule } from "./system.js";\nconst a = Systems.create({ name: "a" });\nconst b = Rule.create({ name: "b" });`); + expect(declared(result?.code)).toEqual([[`{ name: "a" }`, "src/keys.ts#a"]]); + }); + test("leaves calls that aren't a top-level binding's, other namespaces and other methods", async () => { + expect(await identify([ + `import { System, Component } from "@adobe/ecs";`, + `import { System as Other } from "elsewhere";`, + `function make() { const inner = System.create({ name: "inner" }); return inner; }`, + `System.create({ name: "statement" });`, + `const wrapped = [System.create({ name: "wrapped" })];`, + `const other = Other.create({ name: "other" });`, + `const registered = Component.register({ name: "registered" });`, + `const spread = System.create(...[{ name: "spread" }]);`, + `const computed = System["create"]({ name: "computed" });`, + ].join("\n"))).toBe(null); + }); +}); + +describe("moduleIdOf", () => { + /** Packages by directory, as their package.json files name them. */ + const packages = (named: { readonly [directory: string]: string | null }) => (directory: string) => { + const name = named[directory]; + return name === undefined ? undefined : name; + }; + const none = packages({}); + test("is the nearest package's name and the path in it, without a query", async () => { + const app = packages({ "/app": "app", "/repo/packages/shared": "@scope/shared", "/repo/packages/shared/src/nested": null }); + expect(moduleIdOf("/app/src/keys.ts?t=1712", "/app", app)).toBe("app/src/keys.ts"); + expect(moduleIdOf("/app/src/worker.ts?worker_file&type=module", "/app/", app)).toBe("app/src/worker.ts"); + // A workspace package linked from outside the root is named by its package, not its directories. + expect(moduleIdOf("/repo/packages/shared/src/nested/keys.ts", "/repo/apps/web", app)).toBe("@scope/shared/src/nested/keys.ts"); + }); + test("is a dependency's package name and subpath", async () => { + const scoped = packages({ "/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys": "@scope/keys" }); + expect(moduleIdOf("/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys/dist/keys.js?v=3f2a", "/app", scoped)).toBe("@scope/keys/dist/keys.js"); + expect(moduleIdOf("/elsewhere/node_modules/keys/index.js", "/app", none)).toBe("keys/index.js"); + }); + test("is the path from the root without a named package", async () => { + expect(moduleIdOf("/app/src/keys.ts?t=1712", "/app", none)).toBe("src/keys.ts"); + expect(moduleIdOf("/libraries/shared/keys.ts?import", "/app", none)).toBe("../libraries/shared/keys.ts"); + }); + test("uses forward slashes", async () => { + expect(moduleIdOf("C:\\app\\src\\keys.ts", "C:\\app", none)).toBe("src/keys.ts"); + expect(moduleIdOf("C:\\app\\src\\keys.ts", "C:\\app", packages({ "C:/app": "app" }))).toBe("app/src/keys.ts"); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts new file mode 100644 index 000000000..0ee34f1f7 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -0,0 +1,155 @@ +/// +import type { Expression, Node, Program } from 'estree'; +import { posix } from 'node:path'; +import { idsName } from '../../interface/registry/ids.js'; +import { children, isFunction } from './estree.js'; +import { moduleScope } from './module-scope.js'; +import { attachKernelPath } from './paths.js'; +import { insertInto, type Insertion, type SourceMap } from './source-map.js'; + +export type Parse = (code: string) => Program; + +/** Code with its top-level symbols identified, and its map back to the code given. */ +export type Identified = { readonly code: string; readonly map: SourceMap }; + +/** A dependency's path inside the last `node_modules`: its package name and subpath. */ +const dependency = /\/node_modules\/(?!.*\/node_modules\/)(.+)$/; + +/** The name a directory's package.json gives: undefined without one, null for one without a name. */ +export type PackageNameOf = (directory: string) => string | null | undefined; + +/** + * A module's id: the name of the nearest package.json above it that has one and its path in that package, else for a + * dependency its package name and subpath, else its path from `root`. Without a query and with forward slashes, so it's + * the same on every machine and names no directory outside its package. + */ +export const moduleIdOf = (file: string, root: string, packageNameOf: PackageNameOf): string => { + const path = (file.split("?")[0] ?? file).replace(/\\/g, "/"); + for (let directory = posix.dirname(path); ; directory = posix.dirname(directory)) { + const name = packageNameOf(directory); + if (typeof name === "string") return `${name}/${posix.relative(directory, path)}`; + if (posix.dirname(directory) === directory) break; + } + const found = dependency.exec(path)?.[1]; + if (found !== undefined) return found; + const relative = posix.relative(root.replace(/\\/g, "/"), path); + return posix.isAbsolute(relative) ? posix.basename(path) : relative; +}; + +/** Whether a binding named `name` is declared in the module's scope: at top level, or as a `var` in any of its blocks. */ +const declaresInModule = (program: Program, name: string): boolean => { + if (moduleScope(program).has(name)) return true; + const hoists = (node: Node): boolean => { + if (isFunction(node)) return false; + if (node.type === "VariableDeclaration" && node.kind === "var" && node.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === name)) return true; + return children(node).some(hoists); + }; + return hoists(program); +}; + +/** A call of the global `Symbol`. */ +const isSymbolCall = (node: Expression | null | undefined): node is Expression => + node?.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "Symbol"; + +/** The package's namespaces whose constructors declare systems, rules, finalizers and indexes. */ +export type Namespace = "System" | "Rule" | "Finalizer" | "Index"; + +const constructors: { readonly [N in Namespace]: ReadonlySet } = { + System: new Set(["create", "render"]), + Rule: new Set(["create"]), + Finalizer: new Set(["create", "changes"]), + Index: new Set(["create"]), +}; + +const isNamespace = (name: string): name is Namespace => name === "System" || name === "Rule" || name === "Finalizer" || name === "Index"; + +/** Whether an import specifier, resolved from the module being transformed, is a module of the package exporting `namespace`. */ +export type ResolveNamespace = (source: string, namespace: Namespace) => Promise; + +/** The module's local names of the package's namespaces, and of the kernel transform's `attachKernel`. */ +const localsOf = async (program: Program, resolve: ResolveNamespace): Promise<{ readonly namespaces: ReadonlyMap; readonly attach: ReadonlySet }> => { + const namespaces = new Map(); + const attach = new Set(); + for (const statement of program.body) { + if (statement.type !== "ImportDeclaration" || typeof statement.source.value !== "string") continue; + for (const specifier of statement.specifiers) { + if (specifier.type !== "ImportSpecifier" || specifier.imported.type !== "Identifier") continue; + const imported = specifier.imported.name; + if (statement.source.value === attachKernelPath && imported === "attachKernel") attach.add(specifier.local.name); + else if (isNamespace(imported) && await resolve(statement.source.value, imported)) namespaces.set(specifier.local.name, imported); + } + } + return { namespaces, attach }; +}; + +/** + * The declaration a constructor call of a namespace passes, the call possibly wrapped by the kernel transform as + * `attachKernel$(call, description)`; undefined for any other expression. + */ +const declarationOf = (node: Node | null | undefined, namespaces: ReadonlyMap, attach: ReadonlySet): Expression | undefined => { + if (node?.type !== "CallExpression") return undefined; + const { callee } = node; + if (callee.type === "Identifier" && attach.has(callee.name)) { + const [call] = node.arguments; + return call === undefined || call.type === "SpreadElement" ? undefined : declarationOf(call, namespaces, new Set()); + } + if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.property.type !== "Identifier") return undefined; + const namespace = namespaces.get(callee.object.name); + if (namespace === undefined || !constructors[namespace].has(callee.property.name)) return undefined; + const [declaration] = node.arguments; + return declaration === undefined || declaration.type === "SpreadElement" ? undefined : declaration; +}; + +const uniqueName = (code: string, base: string): string => { + let name = base; + for (let n = 1; code.includes(name); n++) name = `${base}${n}`; + return name; +}; + +/** Whether code may hold a constructor call worth resolving its imports for. */ +const mayDeclare = /\.(create|render|changes)\(/; + +/** + * Gives each top-level `Symbol(...)` declaration an id, its module's id and its binding (`src/keys.ts#position`, or + * `#default` for `export default Symbol(...)`): `const`, `let` and `var`, each of several declarators, and their + * exports. The symbol is still the one `Symbol(...)` makes; a function appended to the module records its id in the + * global map the registry reads, so the code imports nothing. Symbols made anywhere else, such as in functions, blocks, + * TypeScript namespaces, class fields or CommonJS exports, have no id, nor does any symbol where `Symbol` or + * `globalThis` isn't the global. + * + * The same bindings initialized by a constructor call of the package's `System`, `Rule`, `Finalizer` or `Index`, imported under + * any local name, give the call's declaration their id, which the constructor takes from the map: a kernel's call as + * the kernel transform wrapped it too. Returns null when nothing is identified. + */ +export const identify = async (code: string, id: string, parse: Parse, resolve: ResolveNamespace): Promise => { + const symbols = code.includes("Symbol("); + const items = mayDeclare.test(code); + if (!symbols && !items) return null; + const program = parse(code); + const { namespaces, attach } = items ? await localsOf(program, resolve) : { namespaces: new Map(), attach: new Set() }; + const found: { readonly value: Expression; readonly binding: string }[] = []; + const consider = (init: Expression | null | undefined, binding: string): void => { + if (isSymbolCall(init)) found.push({ value: init, binding }); + else if (namespaces.size > 0) { + const declaration = declarationOf(init, namespaces, attach); + if (declaration !== undefined) found.push({ value: declaration, binding }); + } + }; + for (const statement of program.body) { + const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "VariableDeclaration") { + for (const declarator of declaration.declarations) if (declarator.id.type === "Identifier") consider(declarator.init, declarator.id.name); + } else if (statement.type === "ExportDefaultDeclaration" && statement.declaration.type === "CallExpression") { + consider(statement.declaration, "default"); + } + } + if (found.length === 0 || declaresInModule(program, "Symbol") || declaresInModule(program, "globalThis")) return null; + const identifier = uniqueName(code, "identify$"); + const insertions: Insertion[] = []; + for (const { value, binding } of found) { + insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(`${id}#${binding}`)})` }); + } + const ids = JSON.stringify(idsName); + insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, id) { const ids = Symbol.for(${ids}); (globalThis[ids] || (globalThis[ids] = new Map())).set(value, id); return value; }\n` }); + return insertInto(code, insertions, id); +}; diff --git a/packages/ecs/src/implementation/compiler/index.ts b/packages/ecs/src/implementation/compiler/index.ts new file mode 100644 index 000000000..e24de3c17 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/index.ts @@ -0,0 +1 @@ +export * from './plugin.js'; diff --git a/packages/ecs/src/implementation/compiler/ir.ts b/packages/ecs/src/implementation/compiler/ir.ts new file mode 100644 index 000000000..ad1c8afdc --- /dev/null +++ b/packages/ecs/src/implementation/compiler/ir.ts @@ -0,0 +1,101 @@ +import type { BinaryOperator } from 'estree'; +import type { Path, Program } from '../world/kernel.js'; +import type { Out } from './out.js'; + +export type Binary = Exclude; +export type Unary = "-" | "+" | "!" | "~"; + +const unaryOperators: ReadonlySet = new Set(["-", "+", "!", "~"]); +const binaryOperators: ReadonlySet = new Set([ + "+", "-", "*", "/", "%", "**", "<", ">", "<=", ">=", "==", "!=", "===", "!==", "&", "|", "^", "<<", ">>", ">>>", +]); + +export const isUnary = (operator: string): operator is Unary => unaryOperators.has(operator); +export const isBinary = (operator: string): operator is Binary => binaryOperators.has(operator); + +/** + * The lowered kernel body: the `Program` subset plus JS-only nodes (lookups, commands, nullish values, runtime + * indexing, JS values). JS is printed from it; a `Program` is derived from it when it has no JS-only nodes. + * Locals and labels are unit-unique numbers. + */ +export type Expression = + | { readonly kind: "number"; readonly value: number } + | { readonly kind: "boolean"; readonly value: boolean } + | { readonly kind: "column"; readonly column: number } + /** A column of the row's parent, at the parent's row: `Unit.upColumns`. */ + | { readonly kind: "upColumn"; readonly column: number } + | { readonly kind: "resource"; readonly index: number; readonly path: Path } + | { readonly kind: "args"; readonly path: Path } + | { readonly kind: "local"; readonly local: number } + | { readonly kind: "unary"; readonly operator: Unary; readonly operand: Expression } + | { readonly kind: "binary"; readonly operator: Binary; readonly left: Expression; readonly right: Expression } + | { readonly kind: "logical"; readonly operator: "&&" | "||" | "??"; readonly left: Expression; readonly right: Expression } + | { readonly kind: "conditional"; readonly test: Expression; readonly consequent: Expression; readonly alternate: Expression } + | { readonly kind: "math"; readonly name: string; readonly arguments: readonly Expression[] } + /** Reads inside convert boolean columns to booleans: a position where `true` and `1` differ. */ + | { readonly kind: "observe"; readonly value: Expression } + | { readonly kind: "lane"; readonly intrinsic: number; readonly lane: number; readonly value: Expression } + /** A lookup through an index's list, in the local `list` holds, and its rows, in the locals `row` holds: as in `Program`. */ + | { readonly kind: "listLength"; readonly list: number; readonly lookup: number } + | { readonly kind: "listChanged"; readonly list: number; readonly lookup: number } + | { readonly kind: "rowLeaf"; readonly row: number; readonly lookup: number; readonly leaf: number } + | { readonly kind: "rowEntity"; readonly row: number; readonly lookup: number } + /** Whether the row in `row` was found: `true` or `null` in JS, so it tests as a lookup's result does. */ + | { readonly kind: "rowFound"; readonly row: number; readonly lookup: number } + // JS only: + | { readonly kind: "nullish"; readonly value: "null" | "undefined" } + | { readonly kind: "string"; readonly value: string } + | { readonly kind: "element"; readonly object: Expression; readonly index: Expression; readonly optional: boolean } + | { readonly kind: "array"; readonly items: readonly Expression[] } + | { readonly kind: "record"; readonly fields: readonly (readonly [string, Expression])[] }; + +export type Statement = + | { readonly kind: "set"; readonly local: number; readonly value: Expression; readonly declare: "const" | "let" | undefined } + /** Declares `let` locals that every path assigns before reading. */ + | { readonly kind: "declare"; readonly locals: readonly number[] } + | { readonly kind: "store"; readonly column: number; readonly value: Expression } + /** Stamps a changed column at the row and its chunk column at the row's chunk. */ + | { readonly kind: "stamp"; readonly column: number; readonly chunks: number } + | { readonly kind: "if"; readonly test: Expression; readonly consequent: Out; readonly alternate: Out } + | { readonly kind: "block"; readonly label: number; readonly body: Out } + | { readonly kind: "loop"; readonly label: number; readonly body: Out } + | { readonly kind: "branch"; readonly label: number } + | { readonly kind: "branchIf"; readonly label: number; readonly test: Expression } + | { readonly kind: "next" } + /** An intrinsic region, as in `Program`; only GPU kernels have them. */ + | { readonly kind: "intrinsic"; readonly id: number; readonly name: string; readonly intrinsic: Program.Intrinsic; readonly operands: readonly (readonly Expression[])[]; readonly body: Out } + /** A lookup through an index, as in `Program`, by `probe`, into the locals `list` and `first` hold. */ + | { readonly kind: "probe"; readonly list: number; readonly first: number; readonly lookup: number; readonly probe: number; readonly values: readonly Expression[] } + | { readonly kind: "listRow"; readonly row: number; readonly list: number; readonly lookup: number; readonly index: Expression } + // JS only: + | { readonly kind: "lookup"; readonly local: number; readonly lookup: number; readonly entity: Expression } + /** + * `KernelCommands`: `spawn` stages a spawn through spawner `list`, an index into `Unit.spawns`, with one value per + * component of the list, into `local`; `begin` starts a despawn (1), add (2) or remove (3). + */ + | { readonly kind: "spawn"; readonly local: number; readonly list: number; readonly values: readonly Expression[] } + | { readonly kind: "begin"; readonly command: 1 | 2 | 3; readonly entity: Expression } + /** A component (index into `keys`) and its value for the current spawn or add. */ + | { readonly kind: "value"; readonly key: number; readonly value: Expression } + /** A component (index into `keys`) for the current remove. */ + | { readonly kind: "component"; readonly key: number }; + +export const number = (value: number): Expression => ({ kind: "number", value }); +export const local = (index: number): Expression => ({ kind: "local", local: index }); +export const not = (operand: Expression): Expression => ({ kind: "unary", operator: "!", operand }); + +/** Whether `expression` reads anything `matches`. */ +export const references = (expression: Expression, matches: (node: Expression) => boolean): boolean => { + if (matches(expression)) return true; + switch (expression.kind) { + case "unary": return references(expression.operand, matches); + case "binary": case "logical": return references(expression.left, matches) || references(expression.right, matches); + case "conditional": return [expression.test, expression.consequent, expression.alternate].some(item => references(item, matches)); + case "math": return expression.arguments.some(item => references(item, matches)); + case "observe": case "lane": return references(expression.value, matches); + case "element": return references(expression.object, matches) || references(expression.index, matches); + case "array": return expression.items.some(item => references(item, matches)); + case "record": return expression.fields.some(([, item]) => references(item, matches)); + default: return false; + } +}; diff --git a/packages/ecs/src/implementation/compiler/kernel-id.ts b/packages/ecs/src/implementation/compiler/kernel-id.ts new file mode 100644 index 000000000..d016e63df --- /dev/null +++ b/packages/ecs/src/implementation/compiler/kernel-id.ts @@ -0,0 +1,33 @@ +/// +import { createHash } from 'node:crypto'; + +const servePrefix = "/@ecs-kernel/"; +const buildPrefix = "\0ecs-kernel:"; + +/** `hash` is set for serve urls: the version of the kernel the description was compiled against. */ +export type KernelId = { readonly parent: string; readonly index: number; readonly hash: string | undefined }; + +const encode = (parent: string, index: number): string => `${Buffer.from(parent).toString("base64url")}/${index}.js`; + +/** A short hash of a kernel's identity (its description and module source). */ +export const kernelHash = (identity: string): string => createHash("sha256").update(identity).digest("base64url").slice(0, 10); + +/** + * The root-absolute url of a kernel module on the dev server, which a page and its module workers import from it. The + * hash makes an edited kernel a new module, and lets `load` reject a stale url. + */ +export const serveUrl = (parent: string, index: number, hash: string): string => `${servePrefix}${encode(parent, index)}?h=${hash}`; + +/** The virtual id of a kernel module emitted as a chunk in a build. */ +export const buildId = (parent: string, index: number): string => `${buildPrefix}${encode(parent, index)}`; + +/** Parses a serve url or build id. */ +export const parseKernelId = (id: string): KernelId | undefined => { + const prefix = id.startsWith(servePrefix) ? servePrefix : id.startsWith(buildPrefix) ? buildPrefix : undefined; + if (prefix === undefined) return undefined; + const [path = "", query = ""] = id.slice(prefix.length).split("?"); + const match = /^([\w-]+)\/(\d+)\.js$/.exec(path); + if (match === null) return undefined; + const hash = new URLSearchParams(query).get("h") ?? undefined; + return { parent: Buffer.from(match[1] ?? "", "base64url").toString(), index: Number(match[2]), hash }; +}; diff --git a/packages/ecs/src/implementation/compiler/key.ts b/packages/ecs/src/implementation/compiler/key.ts new file mode 100644 index 000000000..929f9180e --- /dev/null +++ b/packages/ecs/src/implementation/compiler/key.ts @@ -0,0 +1,32 @@ +import type { AssignmentProperty, MemberExpression, Node, Property } from 'estree'; +import { fail } from './failure.js'; +import type { Out } from './out.js'; +import { findLocal, type Scope } from './scope.js'; +import type { Value } from './value.js'; + +/** + * An index into a value: a static name or element, a binding of the kernel's own module (a component, resource or + * command key), or a runtime value. Other values that are number or string literals, such as constants of other + * modules and locals, fold to static indices. + */ +export type Key = + | { readonly kind: "static"; readonly name: number | string } + | { readonly kind: "binding"; readonly name: string } + | { readonly kind: "value"; readonly value: Value }; + +const keyOf = (node: Node, computed: boolean, scope: Scope, out: Out): Key => { + if (!computed && node.type === "Identifier") return { kind: "static", name: node.name }; + if (node.type === "Literal" && (typeof node.value === "number" || typeof node.value === "string")) return { kind: "static", name: node.value }; + if (node.type === "Identifier" && findLocal(scope, node.name) === undefined && scope.module === scope.unit.module && scope.module.bindings.has(node.name)) { + return { kind: "binding", name: node.name }; + } + if (node.type === "PrivateIdentifier") return fail(node, "private names are not in the kernel subset"); + const value = scope.unit.expression(node, scope, out); + const expression = value.kind === "scalar" ? value.expression : undefined; + if (expression?.kind === "number" || expression?.kind === "string") return { kind: "static", name: expression.value }; + return { kind: "value", value }; +}; + +export const memberKey = (node: MemberExpression, scope: Scope, out: Out): Key => keyOf(node.property, node.computed, scope, out); + +export const propertyKey = (property: Property | AssignmentProperty, scope: Scope, out: Out): Key => keyOf(property.key, property.computed, scope, out); diff --git a/packages/ecs/src/implementation/compiler/link.test.ts b/packages/ecs/src/implementation/compiler/link.test.ts new file mode 100644 index 000000000..78a761b86 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/link.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest'; +import { parseAst } from 'vite'; +import { createLinker, isPending, resolveExport, type Link, type LinkHost } from './link.js'; +import { estreeOf } from './estree.js'; + +const parse = (code: string) => estreeOf(parseAst(code)); + +/** Resolves `./name.js` to `/name.js`, and loads from `files`, which tests may change. */ +const host = (files: Map, gate: Promise = Promise.resolve()): LinkHost => ({ + resolve: async specifier => `/${specifier.replace(/^\.\//, "")}`, + load: async file => { + const code = files.get(file); + await gate; + return code === undefined ? { error: `${file} can't be read` } : { code }; + }, +}); + +/** Resolves an export, loading what it needs. */ +const resolved = async (link: Link, file: string, name: string) => { + for (;;) { + try { + return resolveExport(link, file, name); + } catch (error) { + if (!isPending(error)) throw error; + await error.pending; + } + } +}; + +describe("linker", () => { + test("reports why an export * target failed when nothing else exports the name", async () => { + const files = new Map([["/barrel.js", "export * from './missing.js';"]]); + expect(await resolved(createLinker(host(files), parse), "/barrel.js", "f")).toEqual({ kind: "error", error: "/missing.js can't be read" }); + }); + + test("an ambiguous export * deeper down is ambiguous", async () => { + const files = new Map([ + ["/top.js", "export * from './both.js';\nexport * from './c.js';"], + ["/c.js", "export const f = 3;"], + ["/both.js", "export * from './a.js';\nexport * from './b.js';"], + ["/a.js", "export const f = 1;"], + ["/b.js", "export const f = 2;"], + ]); + expect(await resolved(createLinker(host(files), parse), "/top.js", "f")).toMatchObject({ kind: "error", error: "f is exported by more than one export * of /both.js" }); + }); + + test("a default class export is a class, not a missing export", async () => { + const files = new Map([["/shape.js", "export default class Shape {}"]]); + const result = await resolved(createLinker(host(files), parse), "/shape.js", "default"); + expect(result.kind === "binding" ? result.binding.kind : result.kind).toBe("other"); + }); + + test("a file changed while loading is loaded again", async () => { + const files = new Map([["/value.js", "export const v = 1;"]]); + let open = (): void => {}; + const gate = new Promise(resolve => { open = resolve; }); + const linker = createLinker(host(files, gate), parse); + let first: unknown; + try { + linker.module("/value.js"); + } catch (error) { + first = error; + } + files.set("/value.js", "export const w = 2;"); + linker.forget("/value.js", false); + open(); + if (isPending(first)) await first.pending; + expect(await resolved(linker, "/value.js", "w")).toMatchObject({ kind: "binding", name: "w" }); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/link.ts b/packages/ecs/src/implementation/compiler/link.ts new file mode 100644 index 000000000..fb2239b4a --- /dev/null +++ b/packages/ecs/src/implementation/compiler/link.ts @@ -0,0 +1,202 @@ +import type { Program } from 'estree'; +import { createModule, locateIn, type Module, type ModuleBinding } from './module-scope.js'; + +/** + * Why a specifier names no file the linker can read. `unreadable` marks one the build leaves unread, such as a package + * external to it, rather than one that fails to resolve. + */ +export type Unresolved = { readonly error: string; readonly unreadable?: boolean }; + +/** How the linker reaches other modules. Errors are reasons, reported only if a kernel uses what failed. */ +export type LinkHost = { + /** The file an import specifier names from `importer`. */ + readonly resolve: (specifier: string, importer: string) => Promise; + /** A file's code as JavaScript, and how offsets in it map to source lines when it was compiled from another language. */ + readonly load: (file: string) => Promise<{ readonly code: string; readonly locate?: Module["locate"] } | { readonly error: string }>; +}; + +/** + * Modules as the compiler sees them. Both answer from what the linker has loaded, and when it hasn't loaded something + * yet they throw a `Pending`, which `transformKernels` awaits before compiling the kernel again. + */ +export type Link = { + readonly module: (file: string) => Module | { readonly error: string }; + readonly resolve: (specifier: string, importer: string) => string | Unresolved; +}; + +/** A cache of modules and resolutions shared across transforms. */ +export type Linker = Link & { + /** Adds the module being transformed, so imports back into it resolve to the same module. */ + readonly add: (module: Module) => void; + /** Drops a changed file, and with `resolutions` every resolution, for files that were created or deleted. */ + readonly forget: (file: string, resolutions: boolean) => void; +}; + +type Pending = { readonly pending: Promise }; + +const reason = (error: unknown): string => error instanceof Error ? error.message : String(error); + +const pending = (promise: Promise): never => { + const signal: Pending = { pending: promise }; + throw signal; +}; + +export const isPending = (error: unknown): error is Pending => + typeof error === "object" && error !== null && "pending" in error && error.pending instanceof Promise; + +type Loaded = Module | { readonly error: string }; +type Resolution = string | Unresolved; + +export const createLinker = (host: LinkHost, parse: (code: string) => Program): Linker => { + const modules = new Map(); + const loading = new Map>(); + const resolutions = new Map(); + const resolving = new Map>(); + /** Bumped when a file changes, or every resolution is dropped, so loads and resolutions in flight are discarded. */ + const versions = new Map(); + let resolutionVersion = 0; + /** Starts resolving `specifier` and loading its file, unless either is known or in flight. */ + const prefetch = (specifier: string, importer: string): Promise => { + const key = `${importer}\0${specifier}`; + const resolved = resolutions.get(key); + if (resolved !== undefined) return typeof resolved === "string" ? startLoad(resolved) : Promise.resolve(); + let promise = resolving.get(key); + if (promise === undefined) { + const version = resolutionVersion; + promise = host.resolve(specifier, importer) + .catch((error: unknown) => ({ error: `${specifier} can't be resolved from ${importer}: ${reason(error)}` })) + .then(result => { + if (version !== resolutionVersion) return; + resolutions.set(key, result); + if (typeof result === "string") void startLoad(result); + }) + .finally(() => resolving.delete(key)); + resolving.set(key, promise); + } + return promise; + }; + const startLoad = (file: string): Promise => { + if (modules.has(file)) return Promise.resolve(); + let promise = loading.get(file); + if (promise === undefined) { + promise = load(file).finally(() => loading.delete(file)); + loading.set(file, promise); + } + return promise; + }; + /** + * Loads a module, then starts on the modules it re-exports: compiling stops at each module it doesn't have yet, + * and a namespace's members are usually found through re-exports. + */ + const load = async (file: string): Promise => { + const version = versions.get(file) ?? 0; + const loaded = await host.load(file).catch((error: unknown) => ({ error: `${file} can't be read: ${reason(error)}` })); + if (version !== (versions.get(file) ?? 0)) return; + if ("error" in loaded) { + modules.set(file, loaded); + return; + } + let module: Module; + try { + module = createModule(file, parse(loaded.code), loaded.locate ?? locateIn(loaded.code)); + } catch (error) { + modules.set(file, { error: `${file} can't be parsed: ${reason(error)}` }); + return; + } + modules.set(file, module); + for (const exported of module.exports.values()) if (exported.kind !== "local") void prefetch(exported.specifier, file); + for (const star of module.stars) void prefetch(star, file); + }; + return { + module: file => modules.get(file) ?? pending(startLoad(file)), + resolve: (specifier, importer) => resolutions.get(`${importer}\0${specifier}`) ?? pending(prefetch(specifier, importer)), + add: module => { modules.set(module.file, module); }, + forget: (file, all) => { + modules.delete(file); + loading.delete(file); + versions.set(file, (versions.get(file) ?? 0) + 1); + if (all) { + resolutions.clear(); + resolving.clear(); + resolutionVersion++; + } + }, + }; +}; + +/** A name as the linker resolves it: a definition in its module, a module namespace, or why it isn't one. */ +export type Resolved = + | { readonly kind: "binding"; readonly module: Module; readonly name: string; readonly binding: Exclude } + | { readonly kind: "namespace"; readonly file: string } + /** + * `cause` tells a name a module doesn't export, one several `export *` provide, and one in a module the build leaves + * unread, from failing to tell. + */ + | { readonly kind: "error"; readonly error: string; readonly cause?: "missing" | "ambiguous" | "unreadable" }; + +/** A resolution that names no file, as a `Resolved`. */ +const unresolved = ({ error, unreadable }: Unresolved): Resolved => unreadable === true ? { kind: "error", error, cause: "unreadable" } : { kind: "error", error }; + +const sameResolution = (a: Resolved, b: Resolved): boolean => + a.kind === "binding" && b.kind === "binding" ? a.module === b.module && a.name === b.name + : a.kind === "namespace" && b.kind === "namespace" && a.file === b.file; + +/** Follows a binding of `module` through imports to its definition. */ +export const resolveBinding = (link: Link, module: Module, name: string, binding: ModuleBinding, seen: Set = new Set()): Resolved => { + switch (binding.kind) { + case "import": case "importNamespace": { + const file = link.resolve(binding.specifier, module.file); + if (typeof file !== "string") return unresolved(file); + return binding.kind === "importNamespace" ? { kind: "namespace", file } : resolveExport(link, file, binding.imported, seen); + } + default: + return { kind: "binding", module, name, binding }; + } +}; + +/** What `file` exports as `name`, following re-exports and `export *`. */ +export const resolveExport = (link: Link, file: string, name: string, seen: Set = new Set()): Resolved => { + const module = link.module(file); + if ("error" in module) return { kind: "error", error: module.error }; + const missing: Resolved = { kind: "error", error: `${name} isn't exported by ${file}`, cause: "missing" }; + const key = `${file}\0${name}`; + if (seen.has(key)) return missing; + seen.add(key); + const exported = module.exports.get(name); + if (exported !== undefined) { + switch (exported.kind) { + case "local": { + const binding = module.bindings.get(exported.local); + return binding === undefined ? missing : resolveBinding(link, module, exported.local, binding, seen); + } + case "reexport": case "namespace": { + const target = link.resolve(exported.specifier, file); + if (typeof target !== "string") return unresolved(target); + return exported.kind === "namespace" ? { kind: "namespace", file: target } : resolveExport(link, target, exported.imported, seen); + } + } + } + if (name === "default") return missing; + // Every star is searched, to find ambiguity; what isn't loaded yet is waited for together. A star that is + // ambiguous itself makes the name ambiguous, and one that fails is the reason when no other star exports it. + const found: Resolved[] = []; + const failures: Resolved[] = []; + const waiting: Promise[] = []; + for (const star of module.stars) { + try { + const target = link.resolve(star, file); + const result: Resolved = typeof target === "string" ? resolveExport(link, target, name, new Set(seen)) : unresolved(target); + if (result.kind !== "error") { + if (!found.some(item => sameResolution(item, result))) found.push(result); + } + else if (result.cause === "ambiguous") return result; + else if (result.cause !== "missing") failures.push(result); + } catch (error) { + if (!isPending(error)) throw error; + waiting.push(error.pending); + } + } + if (waiting.length > 0) pending(Promise.all(waiting).then(() => undefined)); + if (found.length > 1) return { kind: "error", error: `${name} is exported by more than one export * of ${file}`, cause: "ambiguous" }; + return found[0] ?? failures[0] ?? missing; +}; diff --git a/packages/ecs/src/implementation/compiler/lists.ts b/packages/ecs/src/implementation/compiler/lists.ts new file mode 100644 index 000000000..77d86204a --- /dev/null +++ b/packages/ecs/src/implementation/compiler/lists.ts @@ -0,0 +1,202 @@ +import type { ArrayExpression, Expression, Node } from 'estree'; +import { children } from './estree.js'; +import { resolveBinding, resolveExport, type Link, type Resolved } from './link.js'; +import type { Module, ModuleBinding } from './module-scope.js'; + +/** The keys a list names, each as an expression of the kernel's module; or why the node isn't a list the compiler can read. */ +export type ListNames = readonly string[] | { readonly error: string }; + +/** How a kernel's module reads its lists of keys, wherever they are declared. */ +export type Lists = { + /** + * The keys a list names: an array literal of the kernel's module-scope identifiers, or a constant, of the kernel's + * module or imported through any re-exports and namespaces, holding an array literal or `Archetype.create` of one. + * A key of another module's list is named by the kernel's binding of the same definition, or else by the list + * itself, as `List[k]`. + */ + readonly names: (node: Node) => ListNames; + /** + * What a command's component argument names: a list, or one key by the expression the kernel's module names it with. + * A name is one key when its definition is a call of the global `Symbol`, identified or not, or a literal, which + * registration rejects for not being a symbol. A name defined in a module the build can't read, such as a package + * external to it, is taken as a key, and registration rejects a list there. Anything else fails with the reason. + */ + readonly component: (node: Node) => Named; +}; + +/** What a command's component argument names, or why the compiler can't tell. */ +export type Named = { readonly kind: "list" } | { readonly kind: "key"; readonly name: string } | { readonly kind: "error"; readonly error: string }; + +/** A list's array literal, the module it's written in, and how the kernel's module refers to it. */ +type Found = { readonly literal: ArrayExpression; readonly module: Module; readonly reference: string }; + +/** What a name is defined as: a list, a key by its definition's identity (null where the build can't read it), or why it's neither. */ +type Followed = Found | { readonly key: string | null } | { readonly error: string }; + +/** Whether a name may be one key as well as a list, which words the reasons it's neither. */ +type Wanted = "list" | "either"; + +/** A call of the global `Symbol` in `module`. */ +const isSymbolCall = (node: Expression, module: Module): boolean => + node.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "Symbol" && !module.bindings.has("Symbol"); + +/** A key's definition: `Symbol(...)`, as the plugin identifies it, `identify$(Symbol(...), id)`, or a literal. */ +const isKey = (node: Expression, module: Module): boolean => { + if (node.type === "Literal") return !("regex" in node); + if (isSymbolCall(node, module)) return true; + if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || !/^identify\$\d*$/.test(node.callee.name)) return false; + const [symbol] = node.arguments; + return module.bindings.get(node.callee.name)?.kind === "function" && symbol !== undefined && symbol.type !== "SpreadElement" && isSymbolCall(symbol, module); +}; + +/** `Archetype.create()`, with `Archetype` imported under any name: its array literal, else null. */ +const createdList = (node: Expression, module: Module): ArrayExpression | null => { + if (node.type !== "CallExpression" || node.arguments.length !== 1) return null; + const { callee } = node; + if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.property.type !== "Identifier" || callee.property.name !== "create") return null; + const binding = module.bindings.get(callee.object.name); + const [list] = node.arguments; + return binding?.kind === "import" && binding.imported === "Archetype" && list.type === "ArrayExpression" ? list : null; +}; + +const fileOf = (module: Module, kernel: Module): string => module === kernel ? "this module" : module.file; + +/** Follows a binding to the array literal or key its constant holds, through imports and constants naming another. */ +const follow = (name: string, binding: ModuleBinding, module: Module, kernel: Module, link: Link | null, reference: string, wanted: Wanted, seen: Set): Followed => { + const identity = `${module.file}\0${name}`; + if (seen.has(identity)) return { error: `${reference} refers to itself` }; + seen.add(identity); + const unread = wanted === "list" ? "the compiler can't read its components" : "the compiler can't tell whether it's a key or a list"; + switch (binding.kind) { + case "const": { + const { init } = binding; + if (init.type === "ArrayExpression") return { literal: init, module, reference }; + const created = createdList(init, module); + if (created !== null) return { literal: created, module, reference }; + if (init.type === "Identifier") { + const aliased = module.bindings.get(init.name); + if (aliased !== undefined) return follow(init.name, aliased, module, kernel, link, reference, wanted, seen); + } + if (isKey(init, module)) return { key: identity }; + const declare = wanted === "list" ? "declare it as an array literal, or Archetype.create of one" : "declare a key as Symbol(...), or a list as an array literal or Archetype.create of one"; + return { error: `${reference} is computed when ${fileOf(module, kernel)} runs, so ${unread}; ${declare}` }; + } + case "import": case "importNamespace": + // Without a linker nothing imported can be read, so a name that may be one key is taken as one. + if (link === null) return wanted === "list" ? { error: `${reference} is imported, and this build doesn't link imports` } : { key: null }; + return followResolved(resolveBinding(link, module, name, binding), kernel, link, reference, wanted, seen); + case "function": case "other": + return { error: `${reference} isn't a module-scope const, so ${unread}` }; + } +}; + +const followResolved = (resolved: Resolved, kernel: Module, link: Link | null, reference: string, wanted: Wanted, seen: Set): Followed => { + switch (resolved.kind) { + case "error": return wanted === "either" && resolved.cause === "unreadable" ? { key: null } : { error: resolved.error }; + case "namespace": return { error: `${reference} is a module namespace, not ${wanted === "list" ? "a list of components" : "a key or a list of components"}` }; + case "binding": return follow(resolved.name, resolved.binding, resolved.module, kernel, link, reference, wanted, seen); + } +}; + +/** The names of a member chain of identifiers, `A.B.C`, outermost first; null for any other node. */ +const chain = (node: Node): readonly string[] | null => { + if (node.type === "Identifier") return [node.name]; + if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return null; + const object = chain(node.object); + return object === null ? null : [...object, node.property.name]; +}; + +/** What a node names: an array literal, or the definition an identifier or a namespace member chain names. */ +const find = (node: Node, kernel: Module, link: Link | null, wanted: Wanted): Followed => { + if (node.type === "ArrayExpression") return { literal: node, module: kernel, reference: "" }; + const names = chain(node); + if (names === null) return { error: "commands and kernel declarations name a list as an array literal, or a constant holding one or Archetype.create of one" }; + const [first, ...members] = names; + const reference = names.join("."); + const binding = kernel.bindings.get(first); + if (binding === undefined) return { error: `${first} isn't bound at module scope` }; + if (members.length === 0) return follow(first, binding, kernel, kernel, link, reference, wanted, new Set()); + const what = wanted === "list" ? "a list" : "a key or a list"; + if (binding.kind !== "importNamespace") return { error: `${first} isn't a module namespace, so ${reference} isn't ${what} the compiler can read` }; + if (link === null) return wanted === "list" ? { error: `${reference} is imported, and this build doesn't link imports` } : { key: null }; + let resolved = resolveBinding(link, kernel, first, binding); + for (const member of members) { + if (resolved.kind === "error") return followResolved(resolved, kernel, link, reference, wanted, new Set()); + if (resolved.kind !== "namespace") return { error: `${reference} isn't ${what} the compiler can read` }; + resolved = resolveExport(link, resolved.file, member); + } + return followResolved(resolved, kernel, link, reference, wanted, new Set()); +}; + +/** The identity of what a binding of `module` defines: its module and name, through imports; null where it can't be followed. */ +const definition = (module: Module, name: string, binding: ModuleBinding, link: Link | null): string | null => { + if (binding.kind !== "import" && binding.kind !== "importNamespace") return `${module.file}\0${name}`; + if (link === null) return null; + const resolved = resolveBinding(link, module, name, binding); + return resolved.kind === "binding" ? `${resolved.module.file}\0${resolved.name}` : null; +}; + +/** + * Lists as `kernel` reads them. `nodes` are the kernel's declaration and body: the kernel's names for keys of other + * modules' lists are found among the module-scope identifiers they use, so only those are followed. + */ +export const createLists = (kernel: Module, link: Link | null, nodes: readonly Node[]): Lists => { + /** Each key definition's name in the kernel's module, filled from the kernel's identifiers when a list of another module is first read. */ + let named: Map | null = null; + const nameOf = (identity: string, fallback: string): string => { + if (named === null) { + const found = new Map(); + const followed = new Set(); + const visit = (node: Node): void => { + if (node.type === "Identifier" && !followed.has(node.name)) { + followed.add(node.name); + const binding = kernel.bindings.get(node.name); + const defined = binding === undefined ? null : definition(kernel, node.name, binding, link); + if (defined !== null && !found.has(defined)) found.set(defined, node.name); + } + for (const child of children(node)) visit(child); + }; + for (const node of nodes) visit(node); + named = found; + } + const name = named.get(identity); + if (name !== undefined) return name; + named.set(identity, fallback); + return fallback; + }; + const names = (node: Node): ListNames => { + const found = find(node, kernel, link, "list"); + if ("key" in found) { + const reference = chain(node)?.join(".") ?? ""; + return { error: `${reference} is a key, not a list of components; list it as [${reference}]` }; + } + if (!("literal" in found)) return found; + const { literal, module, reference } = found; + const result: string[] = []; + for (let k = 0; k < literal.elements.length; k++) { + const element = literal.elements[k]; + const binding = element?.type === "Identifier" ? module.bindings.get(element.name) : undefined; + if (element?.type !== "Identifier" || binding === undefined) { + return { error: module === kernel ? "lists name keys by identifiers bound at module scope" : `${reference} names a key by something other than an identifier bound at the scope of ${module.file}` }; + } + if (module === kernel) { + result.push(element.name); + continue; + } + const fallback = `${reference}[${k}]`; + const identity = definition(module, element.name, binding, link); + result.push(identity === null ? fallback : nameOf(identity, fallback)); + } + return result; + }; + const component = (node: Node): Named => { + const found = find(node, kernel, link, "either"); + if ("literal" in found) return { kind: "list" }; + if ("error" in found) return { kind: "error", error: found.error }; + const reference = chain(node)?.join(".") ?? ""; + // A key named by an identifier is that identifier; one named through a namespace is the kernel's own binding of + // its definition where it has one. + return { kind: "key", name: node.type === "Identifier" || found.key === null ? reference : nameOf(found.key, reference) }; + }; + return { names, component }; +}; diff --git a/packages/ecs/src/implementation/compiler/lookups.ts b/packages/ecs/src/implementation/compiler/lookups.ts new file mode 100644 index 000000000..b3e65ea7d --- /dev/null +++ b/packages/ecs/src/implementation/compiler/lookups.ts @@ -0,0 +1,120 @@ +import type { Expression, Node, ObjectExpression, Pattern, SpreadElement } from 'estree'; +import { children, declaredIn } from './estree.js'; + +type Range = { readonly start: number; readonly end: number }; + +/** How a call passes a key part: as in `world/index-lookups.ts`'s `Shape`, `0` whole, `n` a tuple of `n` scalars, or a comparison's operators. */ +type Form = number | readonly (readonly [string, number])[]; + +/** + * A call of an index lookup in a main-thread body: its callee's source, its key's shape, and the source ranges of the + * values it passes, literals flattened. + */ +export type LookupSite = Range & { readonly callee: Range; readonly shape: readonly Form[]; readonly values: readonly Range[] }; + +/** Arguments a site passes at most (`world/prepared-lookup.ts`). */ +const maximumArguments = 16; + +const operators: ReadonlySet = new Set(["==", "!=", "<", ">", "<=", ">="]); + +/** A property's plain name: an identifier or a string literal, not computed. */ +const nameOf = (key: Node, computed: boolean): string | null => + !computed && key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null; + +/** The names of a declaration's lookups that go through an index: those declared as an object literal with `index`. */ +const indexLookupsOf = (declaration: ObjectExpression): ReadonlySet => { + const names = new Set(); + for (const property of declaration.properties) { + if (property.type !== "Property" || nameOf(property.key, property.computed) !== "lookup" || property.value.type !== "ObjectExpression") continue; + for (const lookup of property.value.properties) { + if (lookup.type !== "Property" || lookup.value.type !== "ObjectExpression") continue; + const name = nameOf(lookup.key, lookup.computed); + const indexed = lookup.value.properties.some(field => field.type === "Property" && nameOf(field.key, field.computed) === "index"); + if (name !== null && indexed) names.add(name); + } + } + return names; +}; + +/** How a body reaches its lookups: the context's name, a name bound to `lookup`, and names bound to lookups themselves. */ +type Reach = { readonly context: string | null; readonly lookup: string | null; readonly direct: ReadonlyMap }; + +const reachOf = (parameter: Pattern | undefined, names: ReadonlySet): Reach | null => { + if (parameter?.type === "Identifier") return { context: parameter.name, lookup: null, direct: new Map() }; + if (parameter?.type !== "ObjectPattern") return null; + for (const property of parameter.properties) { + if (property.type !== "Property" || nameOf(property.key, property.computed) !== "lookup") continue; + if (property.value.type === "Identifier") return { context: null, lookup: property.value.name, direct: new Map() }; + if (property.value.type !== "ObjectPattern") return null; + const direct = new Map(); + for (const inner of property.value.properties) { + if (inner.type !== "Property" || inner.value.type !== "Identifier") continue; + const name = nameOf(inner.key, inner.computed); + if (name !== null && names.has(name)) direct.set(inner.value.name, name); + } + return { context: null, lookup: null, direct }; + } + return null; +}; + +/** The form and values of one argument: a tuple or comparison literal flattened, anything else whole. */ +const formOf = (argument: Expression): { readonly form: Form; readonly values: readonly Range[] } => { + if (argument.type === "ArrayExpression" && argument.elements.length > 0 && argument.elements.every(element => element !== null && element.type !== "SpreadElement")) { + return { form: argument.elements.length, values: argument.elements.flatMap(element => element === null ? [] : [{ start: element.start, end: element.end }]) }; + } + if (argument.type === "ObjectExpression" && argument.properties.length > 0) { + const entries: [string, number][] = []; + const values: Range[] = []; + for (const property of argument.properties) { + if (property.type !== "Property" || property.kind !== "init" || property.method || property.computed) return { form: 0, values: [argument] }; + const operator = nameOf(property.key, false); + if (operator === null || !operators.has(operator) || property.value.type === "AssignmentPattern" || property.value.type === "ObjectPattern" + || property.value.type === "ArrayPattern" || property.value.type === "RestElement") return { form: 0, values: [argument] }; + const operand = formOf(property.value); + entries.push([operator, typeof operand.form === "number" ? operand.form : 0]); + values.push(...typeof operand.form === "number" ? operand.values : [property.value]); + } + return { form: entries, values }; + } + return { form: 0, values: [argument] }; +}; + +/** + * The index lookup calls in a main-thread body, whose context is its parameter at `context`: `context.lookup.name(key)`, + * `lookup.name(key)` and `name(key)` as the parameter's pattern binds them, for lookups the declaration declares with + * `index` in an object literal. A body that redeclares the names it reaches them by is left alone, and so is a call + * with a spread argument or more than `maximumArguments` values, or one inside another's arguments. + */ +export const lookupSites = (body: Node, declaration: ObjectExpression, context: number): readonly LookupSite[] => { + if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return []; + const names = indexLookupsOf(declaration); + if (names.size === 0) return []; + const reach = reachOf(body.params[context], names); + if (reach === null) return []; + const declared = new Set(); + declaredIn(body.body, name => declared.add(name)); + if ((reach.context !== null && declared.has(reach.context)) || (reach.lookup !== null && declared.has(reach.lookup))) return []; + /** Whether a callee is one of the declaration's index lookups, as the body reaches it. */ + const isLookup = (callee: Node): boolean => { + if (callee.type === "Identifier") return reach.direct.has(callee.name) && !declared.has(callee.name); + if (callee.type !== "MemberExpression" || callee.computed || callee.optional || callee.property.type !== "Identifier" || !names.has(callee.property.name)) return false; + const { object } = callee; + if (object.type === "Identifier") return reach.lookup !== null && object.name === reach.lookup; + return reach.context !== null && object.type === "MemberExpression" && !object.computed && !object.optional && object.object.type === "Identifier" + && object.object.name === reach.context && object.property.type === "Identifier" && object.property.name === "lookup"; + }; + const sites: LookupSite[] = []; + const visit = (node: Node): void => { + if (node.type === "CallExpression" && !node.optional && isLookup(node.callee) && node.arguments.every((argument): argument is Expression => argument.type !== "SpreadElement")) { + const forms = node.arguments.map((argument: Expression | SpreadElement) => argument.type === "SpreadElement" ? { form: 0, values: [] } : formOf(argument)); + const values = forms.flatMap(form => form.values); + if (values.length <= maximumArguments) { + sites.push({ start: node.start, end: node.end, callee: { start: node.callee.start, end: node.callee.end }, shape: forms.map(form => form.form), values }); + return; + } + } + for (const child of children(node)) visit(child); + }; + visit(body.body); + return sites; +}; diff --git a/packages/ecs/src/implementation/compiler/math.ts b/packages/ecs/src/implementation/compiler/math.ts new file mode 100644 index 000000000..59f699581 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/math.ts @@ -0,0 +1,9 @@ +/** The `Math` functions allowed in kernels: pure and deterministic. */ +export const mathFunctions: ReadonlySet = new Set([ + "abs", "sign", "floor", "ceil", "round", "trunc", "min", "max", "sqrt", "cbrt", "hypot", + "exp", "expm1", "log", "log1p", "log2", "log10", "pow", + "sin", "cos", "tan", "asin", "acos", "atan", "atan2", "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", + "fround", "imul", "clz32", +]); + +export const mathConstants: ReadonlySet = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]); diff --git a/packages/ecs/src/implementation/compiler/module-scope.ts b/packages/ecs/src/implementation/compiler/module-scope.ts new file mode 100644 index 000000000..0c976a1d9 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/module-scope.ts @@ -0,0 +1,140 @@ +import type { Directive, Expression, FunctionDeclaration, MaybeNamedFunctionDeclaration, ModuleDeclaration, Pattern, Program, Statement } from 'estree'; +import { patternNames } from './estree.js'; + +/** + * A module-scope binding. Only `const` initializers and function declarations can be inlined into kernels, and imports + * of them, which the linker follows (`link.ts`). + */ +export type ModuleBinding = + | { readonly kind: "const"; readonly init: Expression } + | { readonly kind: "function"; readonly node: FunctionDeclaration | MaybeNamedFunctionDeclaration } + /** `import { imported as local } from specifier`; a default import imports `default`. */ + | { readonly kind: "import"; readonly specifier: string; readonly imported: string } + | { readonly kind: "importNamespace"; readonly specifier: string } + | { readonly kind: "other" }; + +export type ModuleScope = ReadonlyMap; + +/** What a module exports under one name. */ +export type Export = + | { readonly kind: "local"; readonly local: string } + | { readonly kind: "reexport"; readonly specifier: string; readonly imported: string } + | { readonly kind: "namespace"; readonly specifier: string }; + +/** A parsed module: its bindings, its exports, and the modules `export *` re-exports, by specifier. */ +export type Module = { + readonly file: string; + readonly bindings: ModuleScope; + readonly exports: ReadonlyMap; + readonly stars: readonly string[]; + /** The source line and column of an offset in the parsed code, both counted from 1. */ + readonly locate: (offset: number) => { readonly line: number; readonly column: number }; +}; + +/** The binding an `export default ` declares. Not a valid identifier, so it can't be shadowed. */ +const defaultBinding = "*default*"; + +const nameOf = (node: { readonly type: string; readonly name?: string; readonly value?: unknown }): string | undefined => + node.type === "Identifier" ? node.name : typeof node.value === "string" ? node.value : undefined; + +const declare = (scope: Map, statement: Statement | ModuleDeclaration | Directive): void => { + switch (statement.type) { + case "ImportDeclaration": { + const specifier = statement.source.value; + if (typeof specifier !== "string") return; + for (const item of statement.specifiers) { + if (item.type === "ImportNamespaceSpecifier") scope.set(item.local.name, { kind: "importNamespace", specifier }); + else scope.set(item.local.name, { kind: "import", specifier, imported: item.type === "ImportDefaultSpecifier" ? "default" : nameOf(item.imported) ?? "" }); + } + return; + } + case "ExportNamedDeclaration": + if (statement.declaration) declare(scope, statement.declaration); + return; + case "ExportDefaultDeclaration": { + const declaration = statement.declaration; + if (declaration.type === "FunctionDeclaration") scope.set(declaration.id?.name ?? defaultBinding, { kind: "function", node: declaration }); + else if (declaration.type === "ClassDeclaration") scope.set(declaration.id?.name ?? defaultBinding, { kind: "other" }); + else scope.set(defaultBinding, { kind: "const", init: declaration }); + return; + } + case "VariableDeclaration": + for (const declarator of statement.declarations) { + if (statement.kind === "const" && declarator.id.type === "Identifier" && declarator.init) { + scope.set(declarator.id.name, { kind: "const", init: declarator.init }); + } else { + for (const name of patternNames(declarator.id)) scope.set(name, { kind: "other" }); + } + } + return; + case "FunctionDeclaration": + if (statement.id) scope.set(statement.id.name, { kind: "function", node: statement }); + return; + case "ClassDeclaration": + if (statement.id) scope.set(statement.id.name, { kind: "other" }); + return; + default: + return; + } +}; + +/** Collects the bindings declared at the top level of `program`. */ +export const moduleScope = (program: Program): ModuleScope => { + const scope = new Map(); + for (const statement of program.body) declare(scope, statement); + return scope; +}; + +const exportsOf = (program: Program): { readonly exports: Map; readonly stars: string[] } => { + const exports = new Map(); + const stars: string[] = []; + for (const statement of program.body) { + if (statement.type === "ExportAllDeclaration" && typeof statement.source.value === "string") { + const exported = statement.exported === null || statement.exported === undefined ? undefined : nameOf(statement.exported); + if (exported === undefined) stars.push(statement.source.value); + else exports.set(exported, { kind: "namespace", specifier: statement.source.value }); + } + else if (statement.type === "ExportDefaultDeclaration") { + const declaration = statement.declaration; + const named = declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration" ? declaration.id?.name + : declaration.type === "Identifier" ? declaration.name : undefined; + exports.set("default", { kind: "local", local: named ?? defaultBinding }); + } + else if (statement.type === "ExportNamedDeclaration") { + const declaration = statement.declaration; + if (declaration?.type === "VariableDeclaration") { + for (const declarator of declaration.declarations) { + for (const name of patternNames(declarator.id)) exports.set(name, { kind: "local", local: name }); + } + } + else if (declaration?.type === "FunctionDeclaration" && declaration.id) exports.set(declaration.id.name, { kind: "local", local: declaration.id.name }); + const source = statement.source?.value; + for (const specifier of statement.specifiers) { + const exported = nameOf(specifier.exported); + const local = nameOf(specifier.local); + if (exported === undefined || local === undefined) continue; + exports.set(exported, typeof source === "string" ? { kind: "reexport", specifier: source, imported: local } : { kind: "local", local }); + } + } + } + return { exports, stars }; +}; + +/** Lines and columns by counting newlines in `code`. */ +export const locateIn = (code: string): Module["locate"] => { + const starts = [0]; + for (let index = code.indexOf("\n"); index >= 0; index = code.indexOf("\n", index + 1)) starts.push(index + 1); + return offset => { + let low = 0; + let high = starts.length - 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (starts[middle] <= offset) low = middle; + else high = middle - 1; + } + return { line: low + 1, column: offset - starts[low] + 1 }; + }; +}; + +export const createModule = (file: string, program: Program, locate: Module["locate"]): Module => + ({ file, bindings: moduleScope(program), ...exportsOf(program), locate }); diff --git a/packages/ecs/src/implementation/compiler/module-value.ts b/packages/ecs/src/implementation/compiler/module-value.ts new file mode 100644 index 000000000..7ded48757 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/module-value.ts @@ -0,0 +1,121 @@ +import type { Expression as SourceExpression, Node } from 'estree'; +import type { Program } from '../world/kernel.js'; +import { isValueType } from '../world/intrinsics.js'; +import { fail, within, type Frame } from './failure.js'; +import { resolveBinding, type Resolved } from './link.js'; +import { isIntrinsicModule } from './paths.js'; +import type { Module, ModuleBinding } from './module-scope.js'; +import type { Out } from './out.js'; +import { rootScope, type Scope } from './scope.js'; +import type { Value } from './value.js'; + +/** Whether `name` is one of the kernel's component or resource keys. */ +export const isKey = (scope: Scope, name: string): boolean => { + const signature = scope.unit.signature; + return signature.read.has(name) || signature.write.has(name) || signature.resources.includes(name) + || signature.lookups.some(lookup => lookup.read.includes(name)); +}; + +/** Records a failure at `position` in `module` as a frame of `name`. */ +export const frameIn = (module: Module, name: string) => (position: number): Frame => ({ name, file: module.file, ...module.locate(position) }); + +const displayName = (name: string): string => name === "*default*" ? "default export" : name; + + +/** An `intrinsic(declaration, implementation)` call's declaration, read from its literals. */ +const intrinsicDeclaration = (node: SourceExpression): Program.Intrinsic => { + if (node.type !== "ObjectExpression") return fail(node, "an intrinsic is declared with an object literal"); + let operation: string | undefined; + let form: Program.Intrinsic["form"] = "call"; + let parameters: Program.ValueType[] = []; + let result: Program.ValueType | undefined; + const text = (value: Node): string => value.type === "Literal" && typeof value.value === "string" ? value.value : fail(value, "intrinsic fields are string literals"); + const type = (value: Node): Program.ValueType => { + const name = text(value); + return isValueType(name) ? name : fail(value, `${name} isn't a WGSL type intrinsics take`); + }; + for (const property of node.properties) { + if (property.type !== "Property" || property.computed || property.key.type !== "Identifier") return fail(property, "intrinsic fields are named literals"); + const value = property.value; + switch (property.key.name) { + case "wgsl": operation = text(value); break; + case "form": { + const name = text(value); + form = name === "infix" || name === "prefix" ? name : fail(value, "an intrinsic's form is infix or prefix"); + break; + } + case "parameters": + parameters = value.type === "ArrayExpression" ? value.elements.map(element => element === null || element.type === "SpreadElement" ? fail(value, "intrinsic parameters are type names") : type(element)) : fail(value, "intrinsic parameters are an array literal"); + break; + case "result": result = type(value); break; + default: return fail(property, `intrinsics have no field ${property.key.name}`); + } + } + if (operation === undefined || result === undefined) return fail(node, "an intrinsic names its wgsl operation and result"); + return { operation, form, parameters, result }; +}; + +/** The implementation of `intrinsic(declaration, implementation)`, and its declaration; null for any other initializer. */ +const intrinsicDefinition = (init: SourceExpression, module: Module, scope: Scope): { readonly implementation: SourceExpression; readonly intrinsic: Program.Intrinsic } | null => { + const link = scope.unit.link; + if (init.type !== "CallExpression" || init.callee.type !== "Identifier" || link === null) return null; + const binding = module.bindings.get(init.callee.name); + if (binding?.kind !== "import") return null; + const resolved = resolveBinding(link, module, init.callee.name, binding); + if (resolved.kind !== "binding" || resolved.name !== "intrinsic" || !isIntrinsicModule(resolved.module.file)) return null; + const [declaration, implementation] = init.arguments; + if (declaration === undefined || declaration.type === "SpreadElement" || implementation === undefined || implementation.type === "SpreadElement") { + return fail(init, "intrinsic takes a declaration and an implementation"); + } + return { implementation, intrinsic: intrinsicDeclaration(declaration) }; +}; + +/** A definition in `module`: a function to inline, or a constant compiled in its own module. */ +const definition = (node: Node, name: string, binding: ModuleBinding, module: Module, scope: Scope, out: Out): Value => { + const unit = scope.unit; + const identity = `${module.file}\0${name}`; + const display = displayName(name); + switch (binding.kind) { + case "function": + return { kind: "function", name: display, identity, params: binding.node.params, body: binding.node.body, module }; + case "const": { + const declared = module === unit.module + ? intrinsicDefinition(binding.init, module, scope) + : within(node, frameIn(module, display), () => intrinsicDefinition(binding.init, module, scope)); + const init = declared?.implementation ?? binding.init; + if (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression") { + if (init.async || init.generator) return fail(init, `${display} must be a plain function to be inlined`); + const intrinsic = declared === null ? {} : { intrinsic: declared.intrinsic }; + return { kind: "function", name: display, identity, params: init.params, body: init.body, module, ...intrinsic }; + } + if (unit.active.has(identity)) return fail(node, `${display} refers to itself`); + unit.active.add(identity); + const compile = (): Value => unit.expression(init, rootScope(unit, true, module), out); + const value = module === unit.module ? compile() : within(node, frameIn(module, display), compile); + unit.active.delete(identity); + return value; + } + case "import": case "importNamespace": + return moduleValue(node, name, binding, module, scope, out); + case "other": + return fail(node, `${display} is not a module-scope const`); + } +}; + +/** A name the linker resolved. */ +export const resolvedValue = (node: Node, resolved: Resolved, scope: Scope, out: Out): Value => { + switch (resolved.kind) { + case "error": return fail(node, resolved.error); + case "namespace": return { kind: "namespace", file: resolved.file }; + case "binding": return definition(node, resolved.name, resolved.binding, resolved.module, scope, out); + } +}; + +/** A binding of `module`, followed through imports to the module that defines it. */ +export const moduleValue = (node: Node, name: string, binding: ModuleBinding, module: Module, scope: Scope, out: Out): Value => { + const unit = scope.unit; + if (module === unit.module && isKey(scope, name)) return fail(node, `${name} is a key; use it to index, as in row[${name}]`); + if (binding.kind !== "import" && binding.kind !== "importNamespace") return definition(node, name, binding, module, scope, out); + if (unit.link === null) return fail(node, `${name} is imported, and this build doesn't link imports`); + return resolvedValue(node, resolveBinding(unit.link, module, name, binding), scope, out); +}; diff --git a/packages/ecs/src/implementation/compiler/out.ts b/packages/ecs/src/implementation/compiler/out.ts new file mode 100644 index 000000000..8bdfd1793 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/out.ts @@ -0,0 +1,6 @@ +import type { Statement } from './ir.js'; + +/** Emitted statements. A function is resolved last, so it can see what the rest of the body needed. */ +export type Out = (Statement | (() => readonly Statement[]))[]; + +export const resolve = (out: Out): readonly Statement[] => out.flatMap(item => typeof item === "function" ? item() : [item]); diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts new file mode 100644 index 000000000..29890a7a5 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -0,0 +1,43 @@ +/// +import { extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = fileURLToPath(import.meta.url); +const extension = extname(here); +const posix = (path: string): string => path.replace(/\\/g, "/"); +const packageRoot = posix(fileURLToPath(new URL("../../..", import.meta.url))).replace(/\/$/, ""); + +/** The runtime hook emitted code imports: the source file when running from `src`, the built one from `dist`. */ +export const attachKernelPath = posix(fileURLToPath(new URL(`../world/attach-kernel${extension}`, import.meta.url))); + +/** The runtime hook for spawns whose values pass positionally. */ +export const prepareSpawnPath = posix(fileURLToPath(new URL(`../world/prepared-spawn${extension}`, import.meta.url))); + +/** The runtime hook for index lookups whose keys pass positionally. */ +export const prepareLookupPath = posix(fileURLToPath(new URL(`../world/prepared-lookup${extension}`, import.meta.url))); + +const intrinsicModules: ReadonlySet = new Set(["src/interface/math/intrinsic.ts", "src/interface/math/intrinsic.js", "dist/interface/math/intrinsic.js"].map(path => `${packageRoot}/${path}`)); + +/** Whether a file is the module math functions declare their WGSL builtins with. */ +export const isIntrinsicModule = (file: string): boolean => intrinsicModules.has(posix(file)); + +/** The package root's module, for kernels that import the package when a build treats it as external. */ +export const packageIndex = posix(fileURLToPath(new URL(`../../index${extension}`, import.meta.url))); + +const systemModules: ReadonlySet = new Set([ + "src/interface/system/system.ts", "src/interface/system/system.js", "dist/interface/system/system.js", + "src/interface/index.ts", "src/interface/index.js", "dist/interface/index.js", "src/index.ts", "src/index.js", "dist/index.js", +].map(path => `${packageRoot}/${path}`)); + +/** Whether a resolved module id is this package's `System` namespace, directly or through the package root. */ +export const isSystemModule = (id: string): boolean => systemModules.has(posix(id.split("?")[0] ?? id)); + +const roots = ["src/interface/index.ts", "src/interface/index.js", "dist/interface/index.js", "src/index.ts", "src/index.js", "dist/index.js"]; +const namespaceModules: { readonly [namespace: string]: ReadonlySet } = Object.fromEntries( + [["System", "system/system"], ["Rule", "rule/rule"], ["Finalizer", "finalizer/finalizer"], ["Index", "indexes/index-type"]].map(([namespace, module]) => [namespace, new Set( + [`src/interface/${module}.ts`, `src/interface/${module}.js`, `dist/interface/${module}.js`, ...roots].map(path => `${packageRoot}/${path}`), + )]), +); + +/** Whether a resolved module id is the module of this package's `namespace`, directly or through the package root. */ +export const isNamespaceModule = (id: string, namespace: string): boolean => namespaceModules[namespace]?.has(posix(id.split("?")[0] ?? id)) ?? false; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts new file mode 100644 index 000000000..fa08a1dcf --- /dev/null +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -0,0 +1,375 @@ +/// +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterAll, expect, test } from 'vitest'; +import { build, createServer, type Rollup, type ViteDevServer } from 'vite'; +import type { KernelDescription, KernelModule } from '../world/kernel.js'; +import { kernelPlugin } from './plugin.js'; + +const source = (path: string) => JSON.stringify(fileURLToPath(new URL(path, import.meta.url))); +const directory = realpathSync(mkdtempSync(join(tmpdir(), "ecs-kernel-"))); +// The application's package, which names its modules' ids. +writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "fixture-app", type: "module" })); +const fixture = join(directory, "fixture.js"); +writeFileSync(fixture, `import { System } from ${source("../../interface/system/system.ts")}; +import { Component } from ${source("../../interface/component/component.ts")}; +import { kernelOf } from ${source("../world/attach-kernel.ts")}; +const score = Symbol("score"); +Component.register(score, { type: { type: "number" } }); +const kernel = System.create({ name: "fixture", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +export const description = kernelOf(kernel); +`); +/** A kernel inlining a TypeScript helper and a function from a package's `dist`. */ +const linked = join(directory, "linked.js"); +const helper = join(directory, "helper.ts"); +const writeHelper = (factor: number) => writeFileSync(helper, `type Factor = number;\nconst factor: Factor = ${factor};\nexport const scaled = (x: number): number => x * factor;\n`); +writeHelper(2); +mkdirSync(join(directory, "node_modules/fake-math/dist"), { recursive: true }); +writeFileSync(join(directory, "node_modules/fake-math/package.json"), JSON.stringify({ name: "fake-math", type: "module", exports: "./dist/index.js" })); +writeFileSync(join(directory, "node_modules/fake-math/dist/index.js"), "export const increment = (x) => x + 1;\n"); +/** The linked kernel's keys, in a module of their own that the edit doesn't evaluate again. */ +writeFileSync(join(directory, "linked-keys.js"), `import { Component } from ${source("../../interface/component/component.ts")}; +export const score = Symbol("score"); +Component.register(score, { type: { type: "number" } }); +`); +writeFileSync(linked, `import { System } from ${source("../../interface/system/system.ts")}; +import { kernelOf } from ${source("../world/attach-kernel.ts")}; +import { scaled } from "./helper.ts"; +import { increment } from "fake-math"; +import { score } from "./linked-keys.js"; +// Serving evaluates this module again after an edit, declaring the same system again with the same keys. +const kernel = System.create({ name: "linked", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); +export const description = kernelOf(kernel); +`); +/** A kernel spawning through a list another module declares, which the test edits. */ +const listing = join(directory, "listing.js"); +const listed = join(directory, "listed.js"); +const writeListed = (keys: string) => writeFileSync(listed, `import { Component } from ${source("../../interface/component/component.ts")}; +import { score } from "./linked-keys.js"; +export const extra = Symbol("extra"); +Component.register(extra, { type: { const: true } }); +export const Listed = [${keys}]; +`); +writeListed("score"); +writeFileSync(listing, `import { System } from ${source("../../interface/system/system.ts")}; +import { kernelOf } from ${source("../world/attach-kernel.ts")}; +import { score } from "./linked-keys.js"; +import { Listed } from "./listed.js"; +const kernel = System.create({ name: "listing", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); +export const description = kernelOf(kernel); +`); +/** An application importing a kernel a library ships uncompiled, from its `dist`. */ +const application = join(directory, "application.js"); +mkdirSync(join(directory, "node_modules/@adobe"), { recursive: true }); +symlinkSync(fileURLToPath(new URL("../../..", import.meta.url)), join(directory, "node_modules/@adobe/ecs")); +mkdirSync(join(directory, "node_modules/kernel-library/dist"), { recursive: true }); +writeFileSync(join(directory, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); +writeFileSync(join(directory, "node_modules/kernel-library/dist/index.js"), `import { Component, System } from "@adobe/ecs"; +const score = Symbol("score"); +Component.register(score, { type: { type: "number" } }); +export const kernel = System.create({ name: "library", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +`); +writeFileSync(application, `import { kernelOf } from ${source("../world/attach-kernel.ts")}; +import { kernel } from "kernel-library"; +export const description = kernelOf(kernel); +`); +/** Two kernels in one module, writing different functions of the entity. */ +const pair = join(directory, "pair.js"); +writeFileSync(pair, `import { System } from ${source("../../interface/system/system.ts")}; +import { Component } from ${source("../../interface/component/component.ts")}; +import { kernelOf } from ${source("../world/attach-kernel.ts")}; +const score = Symbol("score"); +Component.register(score, { type: { type: "number" } }); +const doubled = System.create({ name: "doubled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +const tripled = System.create({ name: "tripled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); +export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; +`); +/** A module of keys and what it declares with them, which serving evaluates again under another query. */ +const keys = (declaration: string, released = "score") => `import { System as Systems } from ${source("../../interface/system/system.ts")}; +import { Component } from ${source("../../interface/component/component.ts")}; +import { Finalizer } from ${source("../../interface/finalizer/finalizer.ts")}; +import { Rule } from ${source("../../interface/rule/rule.ts")}; +import { labelOf } from ${source("../../interface/registry/names.ts")}; +export const score = Symbol("score"); +const shown = Symbol("shown"); +Component.register(score, ${declaration}); +Component.register(shown, { type: { const: true }, derived: true }); +export const rule = Rule.create({ name: "keys/shown", components: { has: [score] }, derive: [shown] }); +export const release = Finalizer.create({ name: "keys/release", components: { read: [${released}] } }, () => {}); +export const system = Systems.create({ name: "keys/write", components: { write: [score] } }, (row) => { row[score] = 1; }); +export const kernel = Systems.create({ name: "keys/write", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 2; }); +export const label = labelOf(score); +`; +const evaluated = join(directory, "evaluated.js"); +writeFileSync(evaluated, keys(`{ type: { type: "number" } }`)); +const changed = join(directory, "changed.js"); +writeFileSync(changed, keys(`{ type: { type: "number" } }`)); +const changedItems = join(directory, "changed-items.js"); +writeFileSync(changedItems, keys(`{ type: { type: "number" } }`)); +/** An application whose keys are its own and a dependency's, and a worker's. */ +const identified = join(directory, "identified.js"); +mkdirSync(join(directory, "node_modules/key-library/dist"), { recursive: true }); +writeFileSync(join(directory, "node_modules/key-library/package.json"), JSON.stringify({ name: "key-library", type: "module", exports: "./dist/index.js" })); +writeFileSync(join(directory, "node_modules/key-library/dist/index.js"), `export const marker = Symbol("marker");\n`); +writeFileSync(join(directory, "worker-keys.js"), `export const message = Symbol("message");\nself.postMessage(String(message.description));\n`); +writeFileSync(identified, `import { labelOf } from ${source("../../interface/registry/names.ts")}; +import { Component } from ${source("../../interface/component/component.ts")}; +import { marker } from "key-library"; +export const score = Symbol("score"); +const twin = Symbol("score"); +export default Symbol(); +Component.register(score, { type: { type: "number" } }); +Component.register(twin, { type: { type: "number" } }); +export const labels = [labelOf(score), labelOf(twin), labelOf(marker)]; +export const worker = () => new Worker(new URL("./worker-keys.js", import.meta.url), { type: "module" }); +`); +/** Kernels adding a key and a list a package external to the build declares, so the build can't read them. */ +mkdirSync(join(directory, "node_modules/external-keys"), { recursive: true }); +writeFileSync(join(directory, "node_modules/external-keys/package.json"), JSON.stringify({ name: "external-keys", type: "module", exports: "./index.js" })); +writeFileSync(join(directory, "node_modules/external-keys/index.js"), `export const heat = Symbol("heat");\nexport const Pair = [Symbol("left"), Symbol("right")];\n`); +const externalKernel = (name: string, added: string) => `import { System } from ${source("../../interface/system/system.ts")}; +import { Component } from ${source("../../interface/component/component.ts")}; +import { kernelOf } from ${source("../world/attach-kernel.ts")}; +import { ${added} } from "external-keys"; +const score = Symbol("score"); +Component.register(score, { type: { type: "number" } }); +const kernel = System.create({ name: "${name}", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); +export const description = kernelOf(kernel); +`; +const externalKey = join(directory, "external-key.js"); +writeFileSync(externalKey, externalKernel("externalKey", "heat")); +const externalList = join(directory, "external-list.js"); +writeFileSync(externalList, externalKernel("externalList", "Pair")); +afterAll(() => rmSync(directory, { recursive: true, force: true })); + +/** Runs a one-column kernel writing a function of the entity, for entities 3 and 4. */ +const runs = (module: KernelModule, description: KernelDescription): readonly number[] => { + const entities = Float64Array.from([3, 4]); + const columns = description.columns.map(column => column.kind === "entity" ? entities : column.kind === "changed" ? new Uint32Array(2) : new Float64Array(2)); + const commands = { spawners: [], begin: () => {}, value: () => {}, component: () => {} }; + module.create(columns.map(() => false), columns.map(() => 1), columns.map(() => 0))(columns, 0, 2, [], [], description.keys, undefined, commands, 1, new Float64Array(0), 0); + return [...columns[description.columns.findIndex(column => column.kind === "field")] ?? []]; +}; + +test("a build emits each kernel as its own chunk, referenced by the description's url", async () => { + const outDir = join(directory, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + build: { outDir, minify: false, lib: { entry: fixture, formats: ["es"], fileName: "main" } }, + }); + const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .filter((item): item is Rollup.OutputChunk => item.type === "chunk"); + const kernel = chunks.find(chunk => chunk.name.startsWith("kernel-")); + const main = chunks.find(chunk => chunk.facadeModuleId === fixture); + expect(kernel).toBeDefined(); + expect(main?.code).not.toContain("columns, start, end"); + const { description }: { description: KernelDescription } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(description.url).toBe(pathToFileURL(join(outDir, kernel?.fileName ?? "")).href); + const module: KernelModule = await import(/* @vite-ignore */ description.url); + expect(runs(module, description)).toEqual([6, 8]); +}); + +const serve = async (use: (server: ViteDevServer) => Promise): Promise => { + const server = await createServer({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + appType: "custom", server: { middlewareMode: true, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true }, + }); + try { + return await use(server); + } finally { + await server.close(); + } +}; + +test("a build gives each kernel the url of its own chunk", async () => { + const outDir = join(directory, "dist-pair"); + const output = await build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + build: { outDir, minify: false, lib: { entry: pair, formats: ["es"], fileName: "main" } }, + }); + const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .find((item): item is Rollup.OutputChunk => item.type === "chunk" && item.facadeModuleId === pair); + const { descriptions }: { descriptions: readonly KernelDescription[] } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(new Set(descriptions.map(description => description.url)).size).toBe(2); + const results = await Promise.all(descriptions.map(async description => runs(await import(/* @vite-ignore */ description.url), description))); + expect(results).toEqual([[6, 8], [9, 12]]); +}); + +/** The url a page's code gives a module's first kernel, as the dev server serves it to a browser and its workers. */ +const servedUrl = async (server: ViteDevServer, module: string): Promise => { + const code = (await server.transformRequest(module))?.code ?? ""; + return /"(\/@ecs-kernel\/[^"]+)"/.exec(code)?.[1] ?? ""; +}; + +test("serve loads a kernel module by url, also before its parent was transformed", async () => { + // Modules are untyped here; their shapes are what the plugin emits. + const description: KernelDescription = await serve(async server => { + const parent = await server.ssrLoadModule(fixture); + expect(server.config.optimizeDeps.exclude).toContain("@adobe/ecs"); + return parent.description; + }); + const url = await serve(server => servedUrl(server, fixture)); + expect(url).toMatch(/^\/@ecs-kernel\/.+\?h=/); + const module = await serve(server => server.ssrLoadModule(url)); + expect(runs({ create: module.create }, description)).toEqual([6, 8]); +}); + +test("serve in node gives each kernel a url a node worker imports, as a pool's do outside the dev server", async () => { + const description: KernelDescription = await serve(async server => (await server.ssrLoadModule(fixture)).description); + expect(description.url).toMatch(/^data:text\/javascript;base64,/); + const { Worker } = await import("node:worker_threads"); + const worker = new Worker(`const { parentPort } = require("node:worker_threads"); +import(${JSON.stringify(description.url)}).then(module => parentPort.postMessage(typeof module.create), error => parentPort.postMessage(String(error)));`, { eval: true }); + const reply = await new Promise(resolve => worker.once("message", resolve)); + await worker.terminate(); + expect(reply).toBe("function"); +}); + +test("serve rejects a url for a different version of the kernel", async () => { + const stale = (await serve(server => servedUrl(server, fixture))).replace(/\?h=.*/, "?h=stale"); + await expect(serve(server => server.ssrLoadModule(stale))).rejects.toThrow(/changed since its description was compiled/); +}); + +test("a build inlines functions from TypeScript modules and packages", async () => { + const outDir = join(directory, "dist-linked"); + const output = await build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + build: { outDir, minify: false, lib: { entry: linked, formats: ["es"], fileName: "main" } }, + }); + const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .filter((item): item is Rollup.OutputChunk => item.type === "chunk"); + const main = chunks.find(chunk => chunk.facadeModuleId === linked); + const { description }: { description: KernelDescription } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + const module: KernelModule = await import(/* @vite-ignore */ description.url); + expect(runs(module, description)).toEqual([7, 9]); +}); + +test("serve compiles kernels again when a file they inline from changes", async () => { + try { + await serve(async server => { + const before: KernelDescription = (await server.ssrLoadModule(linked)).description; + expect(runs({ create: (await server.ssrLoadModule(before.url)).create }, before)).toEqual([7, 9]); + writeHelper(3); + server.watcher.emit("change", helper); + const after: KernelDescription = (await server.ssrLoadModule(linked)).description; + expect(after.url).not.toBe(before.url); + expect(runs({ create: (await server.ssrLoadModule(after.url)).create }, after)).toEqual([10, 13]); + }); + } finally { + writeHelper(2); + } +}); + +test("serve compiles kernels again when a list they import changes", async () => { + try { + await serve(async server => { + const before: KernelDescription = (await server.ssrLoadModule(listing)).description; + expect(before.keys.map(String)).toEqual(["Symbol(score)"]); + writeListed("score, extra"); + server.watcher.emit("change", listed); + const after: KernelDescription = (await server.ssrLoadModule(listing)).description; + expect(after.url).not.toBe(before.url); + expect(after.keys.map(String)).toEqual(["Symbol(score)", "Symbol(extra)"]); + }); + } finally { + writeListed("score"); + } +}); + +test("an application's build compiles the kernels a library ships uncompiled", async () => { + const outDir = join(directory, "dist-application"); + const output = await build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, + }); + const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .find((item): item is Rollup.OutputChunk => item.type === "chunk" && item.facadeModuleId === application); + const { description }: { description: KernelDescription | undefined } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(description).toBeDefined(); + const module: KernelModule = await import(/* @vite-ignore */ description?.url ?? ""); + expect(description === undefined ? [] : runs(module, description)).toEqual([6, 8]); +}); + +test("serve accepts a module of keys evaluated again, which makes the same keys, systems, rules and finalizers by their ids", async () => { + await serve(async server => { + const first = await server.ssrLoadModule(evaluated); + const again = await server.ssrLoadModule(`${evaluated}?evaluated=again`); + const { labelOfItem, sameItem } = await server.ssrLoadModule(JSON.parse(source("../../interface/registry/items.ts"))); + expect(again.score).not.toBe(first.score); + expect([first.label, again.label]).toEqual(["score", "score"]); + expect(again.system).not.toBe(first.system); + expect([sameItem(again.system, first.system), sameItem(again.kernel, first.kernel), sameItem(again.kernel, again.system)]).toEqual([true, true, false]); + expect(again.rule).toBe(first.rule); + expect(again.release).toBe(first.release); + // The two systems share a name, so each is labeled with its id. + expect([labelOfItem(again.system), labelOfItem(first.kernel)]).toEqual(["keys/write (fixture-app/evaluated.js#system)", "keys/write (fixture-app/evaluated.js#kernel)"]); + }); +}); + +test("serve reports a finalizer evaluated again under its id with another declaration", async () => { + await serve(async server => { + await server.ssrLoadModule(changedItems); + writeFileSync(changedItems, keys(`{ type: { type: "number" } }`, "shown")); + await expect(server.ssrLoadModule(`${changedItems}?evaluated=changed`)).rejects.toThrow(`Cannot declare "keys/release" (fixture-app/changed-items.js#release): its id is another item's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); + }); +}); + +test("serve reports a key evaluated again with another declaration", async () => { + await serve(async server => { + await server.ssrLoadModule(changed); + writeFileSync(changed, keys(`{ type: { type: "string" } }`)); + await expect(server.ssrLoadModule(`${changed}?evaluated=changed`)).rejects.toThrow(`Cannot register component "fixture-app/changed.js#score": its id is another key's, with another declaration`); + }); +}); + +/** Every id a build's code gives a symbol. */ +const idsIn = (code: string): readonly string[] => [...code.matchAll(/identify\$[$\d]*\(Symbol\([^)]*\), "([^"]*)"\)/g)].map(match => match[1] ?? ""); + +test("a build gives top-level symbols ids naming no absolute path, also in workers", async () => { + const outDir = join(directory, "dist-identified"); + const output = await build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + build: { outDir, minify: false, rolldownOptions: { input: identified, preserveEntrySignatures: "strict" } }, + }); + const items = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []); + const main = items.find((item): item is Rollup.OutputChunk => item.type === "chunk" && item.facadeModuleId === identified); + expect(new Set(idsIn(main?.code ?? "").filter(id => !id.startsWith("@adobe/ecs/")))).toEqual(new Set(["fixture-app/identified.js#score", "fixture-app/identified.js#twin", "fixture-app/identified.js#default", "key-library/dist/index.js#marker"])); + const worker = items.find(item => item.fileName.includes("worker-keys")); + expect(idsIn(worker === undefined ? "" : worker.type === "chunk" ? worker.code : String(worker.source))).toEqual(["fixture-app/worker-keys.js#message"]); + const ids = items.flatMap(item => idsIn(item.type === "chunk" ? item.code : String(item.source))); + expect(ids.filter(id => id.startsWith("/") || id.includes(directory) || id.includes(tmpdir()))).toEqual([]); + const { labels }: { labels: readonly string[] } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + // Keys sharing a description are labeled by their ids; a key whose description is its own, by its description. + expect(labels).toEqual(["fixture-app/identified.js#score", "fixture-app/identified.js#twin", "marker"]); +}); + +test("a second instance of the plugin in one bundle does nothing, as when an application also lists it for workers", async () => { + const outDir = join(directory, "dist-twice"); + const output = await build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin(), kernelPlugin()], + build: { outDir, minify: false, lib: { entry: fixture, formats: ["es"], fileName: "main" } }, + }); + const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .filter((item): item is Rollup.OutputChunk => item.type === "chunk"); + expect(chunks.filter(chunk => chunk.name.startsWith("kernel-"))).toHaveLength(1); + const main = chunks.find(chunk => chunk.facadeModuleId === fixture); + expect(idsIn(main?.code ?? "").filter(id => id.startsWith("fixture-app/"))).toEqual(["fixture-app/fixture.js#score"]); + const { description }: { description: KernelDescription } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(runs(await import(/* @vite-ignore */ description.url), description)).toEqual([6, 8]); +}); + +test("a build takes a name from a package external to it as one key, and registration rejects a list there", async () => { + const outDir = join(directory, "dist-external"); + const output = await build({ + configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + build: { outDir, minify: false, rolldownOptions: { input: [externalKey, externalList], external: ["external-keys"], preserveEntrySignatures: "strict" } }, + }); + const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .filter((item): item is Rollup.OutputChunk => item.type === "chunk"); + const url = (entry: string) => pathToFileURL(join(outDir, chunks.find(chunk => chunk.facadeModuleId === entry)?.fileName ?? "")).href; + const { description }: { description: KernelDescription } = await import(/* @vite-ignore */ url(externalKey)); + expect(description.keys.map(String)).toEqual(["Symbol(heat)"]); + await expect(import(/* @vite-ignore */ url(externalList))).rejects.toThrow(`System "externalList" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); +}); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts new file mode 100644 index 000000000..0dc533c0c --- /dev/null +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -0,0 +1,223 @@ +/// +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; +import type { Plugin, Rollup, ViteDevServer } from 'vite'; +import { estreeOf } from './estree.js'; +import { describeFailure } from './failure.js'; +import { identify as identifyModule, moduleIdOf, type PackageNameOf } from './identify.js'; +import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; +import { createLinker, type LinkHost, type Linker } from './link.js'; +import { isNamespaceModule, isSystemModule, packageIndex } from './paths.js'; +import { locateThroughMap } from './source-map.js'; +import { transformKernels } from './transform.js'; + +const scripts = /\.[cm]?[jt]sx?$/; +const packageName = "@adobe/ecs"; + +type CachedKernel = { readonly source: string; readonly hash: string }; + +const loaders: { readonly [extension: string]: "ts" | "tsx" | "jsx" } = { ts: "ts", mts: "ts", cts: "ts", tsx: "tsx", jsx: "jsx" }; + +/** A file's code as JavaScript, with types stripped and lines mapped back to the source. */ +const loadFile = async (file: string): Promise<{ readonly code: string; readonly locate?: ReturnType }> => { + const code = await readFile(file, "utf8"); + const loader = loaders[file.slice(file.lastIndexOf(".") + 1)]; + if (loader === undefined) return { code }; + const { transformWithOxc } = await import("vite"); + const result = await transformWithOxc(code, file, { lang: loader, sourcemap: true }); + return { code: result.code, locate: result.map === undefined ? undefined : locateThroughMap(result.code, result.map.mappings) }; +}; + +/** The name each directory's package.json gives, read once per directory. */ +const packageNames = (): PackageNameOf => { + const names = new Map(); + return directory => { + if (names.has(directory)) return names.get(directory); + let name: string | null | undefined; + try { + const json: unknown = JSON.parse(readFileSync(`${directory}/package.json`, "utf8")); + const named: unknown = typeof json === "object" && json !== null ? Reflect.get(json, "name") : undefined; + name = typeof named === "string" && named !== "" ? named : null; + } + catch { + // No package.json here, or none that parses. + name = undefined; + } + names.set(directory, name); + return name; + }; +}; + +/** Resolves imports through the build, from whichever transform runs. */ +const buildHost = (resolver: () => Rollup.PluginContext): LinkHost => ({ + resolve: async (specifier, importer) => { + const resolved = await resolver().resolve(specifier, importer, { skipSelf: true }); + if (resolved === null) return { error: `${specifier} can't be resolved from ${importer}` }; + if (resolved.external) { + return specifier === packageName ? packageIndex : { error: `${specifier} is external to the build, so kernels can't inline from it`, unreadable: true }; + } + const id = resolved.id.split("?")[0] ?? resolved.id; + return id.includes("\0") || !isAbsolute(id) ? { error: `${specifier} isn't a file` } : id; + }, + load: loadFile, +}); + +/** + * Compiles kernel systems (`kernel: "cpu"` or `"gpu"`). Runs after TypeScript is stripped and before minification, so it sees plain JS + * with source names. Each kernel's `create` becomes its own module at `description.url`: served from a + * root-absolute url in dev, and emitted as a chunk in a build. + * + * Functions and constants kernels import are read from their files, with types stripped, and inlined (`link.ts`). + * The application's build compiles every kernel, its dependencies' too: a library ships its kernels uncompiled. + * + * Also gives each top-level `Symbol(...)` declaration an id, its package's name, its module's path in the package and + * its binding, by which the registry knows the symbol a module evaluated again makes as the same key, and in the same + * way the declaration of each top-level binding's system, rule or finalizer, as the same item (`identify.ts`). + * + * Dev dep pre-bundling bypasses plugins and breaks the runtime's worker url, so `@adobe/ecs` is excluded from it. + * Consumers must also add to `optimizeDeps.exclude` any dependency that defines kernels or keys, imports + * `@adobe/ecs`, or has functions kernels import; otherwise its kernels reach the runtime uncompiled, which fails + * at registration, its keys have no ids, or its functions can't be read. + * + * The plugin adds itself to worker bundles; an instance after the first in one bundle does nothing. + */ +/** Where a served kernel's url goes in node's code, until its module is compiled. */ +const inlinePlaceholder = (index: number, hash: string): string => `@ecs-kernel-inline/${index}/${hash}`; + +/** Code with each kernel's placeholder replaced by its module as a `data:` url, which node imports on any thread. */ +const inlineKernels = (code: string, kernels: readonly string[], hashes: readonly string[]): string => + kernels.reduce((inlined, source, index) => inlined.replaceAll( + JSON.stringify(inlinePlaceholder(index, hashes[index] ?? "")), + JSON.stringify(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`), + ), code); + +export const kernelPlugin = (): Plugin => { + let serve = true; + let root = process.cwd(); + /** Whether an earlier instance of the plugin is in the same bundle. */ + let repeated = false; + let server: ViteDevServer | undefined; + const packageNameOf = packageNames(); + /** + * A linker per environment, since client and SSR builds resolve imports under different conditions. Each resolves + * through its environment's latest transform. + */ + const contexts = new Map(); + const linkers = new Map(); + const linkerFor = (ssr: boolean, context: Rollup.PluginContext): Linker => { + contexts.set(ssr, context); + const existing = linkers.get(ssr); + if (existing !== undefined) return existing; + const linker = createLinker(buildHost(() => contexts.get(ssr) ?? context), input => estreeOf((contexts.get(ssr) ?? context).parse(input))); + linkers.set(ssr, linker); + return linker; + }; + /** Modules whose kernels inline each file, and the files each module's kernels inline. */ + const dependents = new Map>(); + const dependencies = new Map(); + const depend = (parent: string, files: readonly string[]): void => { + for (const file of dependencies.get(parent) ?? []) dependents.get(file)?.delete(parent); + dependencies.set(parent, files); + for (const file of files) { + const parents = dependents.get(file) ?? new Set(); + parents.add(parent); + dependents.set(file, parents); + } + }; + /** Kernel modules by parent module id, kept current as parents are transformed. */ + const kernels = new Map(); + /** Reference ids of the kernel chunks a build emitted. */ + const chunks = new Set(); + /** + * The bundler gives chunks emitted under one name one reference, so each kernel's name carries its hash. Its + * `create` is imported at run time, which the bundle can't see, so the chunk keeps its exports even where the + * build drops entries' signatures, as an application's does. + */ + const emitChunk = (context: Rollup.PluginContext, id: string, hash: string): string => { + const referenceId = context.emitFile({ type: "chunk", id, name: `kernel-${hash}`, preserveSignature: "exports-only" }); + chunks.add(referenceId); + return referenceId; + }; + const plugin: Plugin = { + name: `${packageName}/kernel`, + enforce: "post", + // Dep pre-bundling breaks the runtime's `new Worker(new URL("./worker.js", import.meta.url))`. + config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => [kernelPlugin()] } }), + configResolved: config => { + serve = config.command === "serve"; + root = config.root; + repeated = config.plugins.find(other => other.name === plugin.name) !== plugin; + }, + configureServer: devServer => { + server = devServer; + }, + // A kernel chunk's url is relative to the chunk that references it, as Rollup resolves it, rather than to `base` + // as Vite resolves other files, so a build's kernels load wherever it's served from or run. + resolveFileUrl: { + order: "pre", + handler: ({ referenceId, relativePath }) => chunks.has(referenceId) ? `new URL(${JSON.stringify(relativePath)}, import.meta.url).href` : null, + }, + resolveId: id => repeated || parseKernelId(id) === undefined ? null : id, + async load(id) { + if (repeated) return null; + const kernel = parseKernelId(id); + if (kernel === undefined) return null; + // Transforming the parent fills the cache: for a kernel requested first, or after a restart. + // In dev, `this.load` doesn't read files from disk, so the server's own pipeline does it. + if (!kernels.has(kernel.parent)) { + if (server) await server.transformRequest(kernel.parent); + else await this.load({ id: kernel.parent }); + } + const cached = kernels.get(kernel.parent)?.[kernel.index]; + if (cached === undefined) return this.error(`no kernel ${kernel.index} in ${kernel.parent}`); + // A different version would pair the description's columns with another kernel's code. + if (kernel.hash !== undefined && kernel.hash !== cached.hash) { + return this.error(`kernel ${kernel.index} in ${kernel.parent} changed since its description was compiled; reload`); + } + return cached.source; + }, + // Vite only soft-invalidates importers of a changed file, which doesn't transform them again, so kernels that + // inline from it are invalidated here. + watchChange(id, change) { + for (const linker of linkers.values()) linker.forget(id, change.event !== "update"); + for (const parent of dependents.get(id) ?? []) { + for (const module of server?.moduleGraph.getModulesByFile(parent) ?? []) server?.moduleGraph.invalidateModule(module); + } + }, + async transform(code, id, options) { + if (repeated || id.includes("\0") || !scripts.test(id.split("?")[0] ?? id)) return null; + const parse = (input: string) => estreeOf(this.parse(input)); + const identify = (input: string) => identifyModule(input, moduleIdOf(id, root, packageNameOf), parse, async (source, namespace) => { + const resolved = await this.resolve(source, id); + return resolved !== null && (resolved.external ? source === packageName : isNamespaceModule(resolved.id, namespace)); + }); + const ssr = options?.ssr === true; + const linker = linkerFor(ssr, this); + const hashes: string[] = []; + const transformed = await transformKernels(code, id, parse, async source => { + const resolved = await this.resolve(source, id); + return resolved !== null && isSystemModule(resolved.id); + }, (index, identity) => { + const hash = kernelHash(identity); + hashes[index] = hash; + if (!serve) return `import.meta.ROLLUP_FILE_URL_${emitChunk(this, buildId(id, index), hash)}`; + // Served to node, a kernel's module is its url, so a pool's workers, which import it outside the server, read it too. + return JSON.stringify(ssr ? inlinePlaceholder(index, hash) : serveUrl(id, index, hash)); + }, linker); + const result = ssr && transformed !== null && !("failure" in transformed) ? { ...transformed, code: inlineKernels(transformed.code, transformed.kernels, hashes) } : transformed; + const parent = id.split("?")[0] ?? id; + if (result === null) { + kernels.delete(id); + depend(parent, []); + return await identify(code); + } + for (const dependency of result.dependencies) this.addWatchFile(dependency); + depend(parent, result.dependencies); + if ("failure" in result) return this.error(describeFailure(result.failure), result.failure.position); + kernels.set(id, result.kernels.map((source, index) => ({ source, hash: hashes[index] ?? "" }))); + return await identify(result.code) ?? { code: result.code, map: null }; + }, + }; + return plugin; +}; diff --git a/packages/ecs/src/implementation/compiler/print.ts b/packages/ecs/src/implementation/compiler/print.ts new file mode 100644 index 000000000..9fe87012e --- /dev/null +++ b/packages/ecs/src/implementation/compiler/print.ts @@ -0,0 +1,104 @@ +import { chunkShift, type Path } from '../world/kernel.js'; +import type { Expression, Statement } from './ir.js'; +import { resolve, type Out } from './out.js'; +import { rowLabel } from './statement.js'; +import type { Unit } from './unit.js'; + +/** A number as JS source that reads back as the same f64. */ +export const printNumber = (value: number): string => + Number.isNaN(value) ? "NaN" : value === Infinity ? "Infinity" : Object.is(value, -0) ? "(-0)" : value < 0 ? `(${String(value)})` : String(value); + +const printPath = (path: Path): string => path.map(item => `[${JSON.stringify(item)}]`).join(""); + +export const localName = (unit: Unit, local: number): string => `${unit.names.get(local) ?? "t"}_${local}`; + +/** Prints an expression. `observed` converts boolean column reads. */ +export const printExpression = (unit: Unit, expression: Expression, observed: boolean): string => { + const print = (item: Expression): string => printExpression(unit, item, observed); + switch (expression.kind) { + case "number": return printNumber(expression.value); + case "boolean": return String(expression.value); + case "column": { + const column = expression.column; + if (!observed) return `c${column}[x${column}]`; + unit.booleans.add(column); + return `(b${column} ? c${column}[x${column}] !== 0 : c${column}[x${column}])`; + } + case "upColumn": { + const column = expression.column; + const read = `u${column}[p * us${column} + uo${column}]`; + if (!observed) return read; + unit.upBooleans.add(column); + return `(ub${column} ? ${read} !== 0 : ${read})`; + } + case "resource": return `r${expression.index}${printPath(expression.path)}`; + case "args": return `args${printPath(expression.path)}`; + case "local": return localName(unit, expression.local); + case "unary": return `(${expression.operator}${print(expression.operand)})`; + case "binary": case "logical": return `(${print(expression.left)} ${expression.operator} ${print(expression.right)})`; + case "conditional": return `(${print(expression.test)} ? ${print(expression.consequent)} : ${print(expression.alternate)})`; + case "math": return `Math.${expression.name}(${expression.arguments.map(print).join(", ")})`; + case "observe": return printExpression(unit, expression.value, true); + case "lane": return print(expression.value); + // Lookups through indexes read through their reader, `l${lookup}`, and the handles each site holds. + case "listLength": return `l${expression.lookup}.length(${localName(unit, expression.list)})`; + case "listChanged": return `l${expression.lookup}.changed(${localName(unit, expression.list)})`; + case "rowLeaf": return `l${expression.lookup}.leaf(${localName(unit, expression.row)}, ${expression.leaf})`; + case "rowEntity": return `l${expression.lookup}.entity(${localName(unit, expression.row)})`; + case "rowFound": return `(${localName(unit, expression.row)}.f === 1 ? true : null)`; + case "nullish": return expression.value; + case "string": return JSON.stringify(expression.value); + case "element": return `${print(expression.object)}${expression.optional ? "?." : ""}[${print(expression.index)}]`; + case "array": return `[${expression.items.map(print).join(", ")}]`; + case "record": return `{ ${expression.fields.map(([name, item]) => `${JSON.stringify(name)}: ${print(item)}`).join(", ")} }`; + } +}; + +type Labels = ReadonlyMap; + +const printStatement = (unit: Unit, statement: Statement, labels: Labels): string => { + const value = (item: Expression): string => printExpression(unit, item, false); + const branch = (label: number): string => `${labels.get(label) === "loop" ? "continue" : "break"} label_${label};`; + switch (statement.kind) { + case "set": { + const declare = statement.declare === undefined ? "" : `${statement.declare} `; + return `${declare}${localName(unit, statement.local)} = ${printExpression(unit, statement.value, unit.observed.has(statement.local))};`; + } + case "declare": return `let ${statement.locals.map(item => localName(unit, item)).join(", ")};`; + case "store": return `c${statement.column}[x${statement.column}] = ${value(statement.value)};`; + case "stamp": { + const row = `c${statement.column}[x${statement.column}] = tick;`; + return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.chunks}[i >> ${chunkShift}] = tick;`; + } + case "if": { + const alternate = printStatements(unit, statement.alternate, labels); + return `if (${value(statement.test)}) {\n${printStatements(unit, statement.consequent, labels)}\n}${alternate === "" ? "" : ` else {\n${alternate}\n}`}`; + } + case "block": + return `label_${statement.label}: {\n${printStatements(unit, statement.body, new Map([...labels, [statement.label, "block"]]))}\n}`; + case "loop": { + // `for (;;)` repeats where a WASM loop falls off its end, so a trailing branch back is implicit, and otherwise the loop exits. + const body = resolve(statement.body); + const last = body[body.length - 1]; + const repeats = last?.kind === "branch" && last.label === statement.label; + const inner = new Map([...labels, [statement.label, "loop" as const]]); + const printed = printStatements(unit, repeats ? body.slice(0, -1) : body, inner); + return `label_${statement.label}: for (;;) {\n${printed}${repeats ? "" : `\nbreak label_${statement.label};`}\n}`; + } + case "branch": return branch(statement.label); + case "branchIf": return `if (${value(statement.test)}) ${branch(statement.label)}`; + case "next": return `continue ${rowLabel};`; + case "intrinsic": return printStatements(unit, statement.body, labels); + case "lookup": return `const ${localName(unit, statement.local)} = l${statement.lookup}(${value(statement.entity)});`; + case "probe": return `l${statement.lookup}.probe(${[statement.probe, localName(unit, statement.list), localName(unit, statement.first), ...statement.values.map(value)].join(", ")});`; + case "listRow": return `l${statement.lookup}.row(${localName(unit, statement.row)}, ${localName(unit, statement.list)}, ${value(statement.index)});`; + case "spawn": return `const ${localName(unit, statement.local)} = spawn${statement.list}(${statement.values.map(value).join(", ")});`; + case "begin": return `commands.begin(${statement.command}, ${value(statement.entity)});`; + case "value": return `commands.value(${statement.key}, ${value(statement.value)});`; + case "component": return `commands.component(${statement.key});`; + } +}; + +/** Prints statements as JS. */ +export const printStatements = (unit: Unit, statements: Out | readonly Statement[], labels: Labels = new Map()): string => + resolve([...statements]).map(statement => printStatement(unit, statement, labels)).join("\n"); diff --git a/packages/ecs/src/implementation/compiler/program.ts b/packages/ecs/src/implementation/compiler/program.ts new file mode 100644 index 000000000..df94de394 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/program.ts @@ -0,0 +1,232 @@ +import type { Path, Program } from '../world/kernel.js'; +import type { Expression, Statement } from './ir.js'; +import { resolve } from './out.js'; + +const all = (items: readonly T[], map: (item: T) => R | undefined): R[] | undefined => { + const result: R[] = []; + for (const item of items) { + const mapped = map(item); + if (mapped === undefined) return undefined; + result.push(mapped); + } + return result; +}; + +const unwrap = (node: Expression): Expression => node.kind === "observe" ? unwrap(node.value) : node; + +type Guards = ReadonlySet; +const union = (a: Guards, b: Guards): Guards => new Set([...a, ...b]); +const none: Guards = new Set(); +/** The handle standing for `up`, which isn't looked up. */ +const upHandle = -1; + +/** + * Derives a `Program` from the lowered body, or null if it uses what WASM kernels don't support. Locals are renumbered + * densely, branch labels become depths counting enclosing blocks and loops, and each lookup call site gets a handle. + * A lookup result may only be null-tested or read by a static leaf access; a non-optional access must be under a + * guard proving the lookup found its entity, since JS would throw where WASM reads NaN. With `nameOf`, the program + * carries each local's name. + */ +export const toProgram = (statements: readonly Statement[], nameOf?: (local: number) => string, accumulators: readonly number[] = [], upLocal: number | null = null, listed = upLocal !== null): Program | null => { + const locals = new Map(); + const localIndex = (local: number): number => { + const existing = locals.get(local); + if (existing !== undefined) return existing; + locals.set(local, locals.size); + return locals.size - 1; + }; + /** Handles by the local holding a lookup result. */ + const handles = new Map(); + const handleOf = (node: Expression): number | undefined => { + const inner = unwrap(node); + // A row a lookup through an index gave tests as found, as a lookup's result does. + if (inner.kind === "rowFound") return handleFor(inner.row); + return inner.kind !== "local" ? undefined : inner.local === upLocal ? upHandle : handles.get(inner.local); + }; + /** The handle of a local holding a lookup through an index's list or row, made at its first use. */ + const handleFor = (local: number): number => { + const existing = handles.get(local); + if (existing !== undefined) return existing; + handles.set(local, handles.size); + return handles.size - 1; + }; + /** Whether `handle` found its entity, or for `up`, whether the call's rows have a parent. */ + const found = (handle: number): Program.Expression => handle === upHandle ? { kind: "upFound" } : { kind: "found", handle }; + const isNull = (node: Expression): boolean => { + const inner = unwrap(node); + return inner.kind === "nullish" && inner.value === "null"; + }; + /** `handle` and whether the test means "found", for `other !== null` and its variants. */ + const nullTest = (node: Expression): { readonly handle: number; readonly found: boolean } | undefined => { + if (node.kind !== "binary" || !["===", "!==", "==", "!="].includes(node.operator)) return undefined; + const handle = isNull(node.right) ? handleOf(node.left) : isNull(node.left) ? handleOf(node.right) : undefined; + return handle === undefined ? undefined : { handle, found: node.operator === "!==" || node.operator === "!=" }; + }; + /** Handles a test proves found when it is `truthy`. */ + const proves = (node: Expression, truthy: boolean): Guards => { + const inner = unwrap(node); + const handle = handleOf(inner); + if (handle !== undefined) return truthy ? new Set([handle]) : none; + const test = nullTest(inner); + if (test !== undefined) return test.found === truthy ? new Set([test.handle]) : none; + if (inner.kind === "unary" && inner.operator === "!") return proves(inner.operand, !truthy); + if (inner.kind === "logical" && inner.operator === (truthy ? "&&" : "||")) return union(proves(inner.left, truthy), proves(inner.right, truthy)); + return none; + }; + /** A static leaf access into a lookup result, or undefined if `node` isn't one. */ + const leaf = (node: Expression, guards: Guards): Program.Expression | null | undefined => { + const keys: Expression[] = []; + let first: Expression = node; + let optional = false; + while (first.kind === "element") { + keys.unshift(first.index); + optional = first.optional; + first = first.object; + } + const handle = handleOf(first); + if (handle === undefined) return undefined; + if (handle === upHandle) return null; + const [read, ...rest] = keys; + if (read?.kind !== "number" || (!optional && !guards.has(handle))) return null; + const path: (number | string)[] = []; + for (const key of rest) { + if (key.kind !== "number" && key.kind !== "string") return null; + path.push(key.value); + } + const leafPath: Path = path; + return { kind: "lookupLeaf", handle, read: read.value, path: leafPath }; + }; + /** `test` is set where only truthiness matters, so a lookup result may stand for `found`. */ + const expression = (node: Expression, guards: Guards, test: boolean): Program.Expression | undefined => { + const handle = handleOf(node); + if (handle !== undefined) return test ? found(handle) : undefined; + const check = nullTest(node); + if (check !== undefined) { + const tested = found(check.handle); + return check.found ? tested : { kind: "unary", operator: "!", operand: tested }; + } + switch (node.kind) { + case "number": case "boolean": case "column": case "resource": case "args": + return node; + // JS throws reading a parent that isn't there, so WASM reads one only where a test proves it is. + case "upColumn": return guards.has(upHandle) ? { kind: "upLeaf", column: node.column } : undefined; + case "local": return { kind: "local", local: localIndex(node.local) }; + case "unary": { + const operand = expression(node.operand, guards, node.operator === "!"); + return operand && { kind: "unary", operator: node.operator, operand }; + } + case "binary": { + const left = expression(node.left, guards, false); + const right = expression(node.right, guards, false); + return left && right && { kind: "binary", operator: node.operator, left, right }; + } + case "logical": { + if (node.operator === "??") return undefined; + const left = expression(node.left, guards, test); + const right = expression(node.right, node.operator === "&&" ? union(guards, proves(node.left, true)) : union(guards, proves(node.left, false)), test); + return left && right && { kind: "logical", operator: node.operator, left, right }; + } + case "conditional": { + const condition = expression(node.test, guards, true); + const consequent = expression(node.consequent, union(guards, proves(node.test, true)), test); + const alternate = expression(node.alternate, union(guards, proves(node.test, false)), test); + return condition && consequent && alternate && { kind: "conditional", test: condition, consequent, alternate }; + } + case "math": { + const items = all(node.arguments, item => expression(item, guards, false)); + return items && { kind: "math", name: node.name, arguments: items }; + } + case "observe": return expression(node.value, guards, test); + case "lane": { + const value = expression(node.value, guards, false); + return value && { kind: "lane", intrinsic: node.intrinsic, lane: node.lane, value }; + } + case "element": return leaf(node, guards) ?? undefined; + // A row's leaves read NaN where there's no row, in JS kernels as in WASM, so they need no guard. + case "listLength": return { kind: "listLength", handle: handleFor(node.list) }; + case "listChanged": return { kind: "listChanged", handle: handleFor(node.list) }; + case "rowLeaf": return { kind: "rowLeaf", handle: handleFor(node.row), leaf: node.leaf }; + case "rowEntity": return { kind: "rowEntity", handle: handleFor(node.row) }; + default: return undefined; + } + }; + let intrinsics = 0; + const exits = (body: readonly Statement[]): boolean => body[body.length - 1]?.kind === "next"; + const block = (body: readonly Statement[], labels: readonly number[], outer: Guards): Program.Statement[] | undefined => { + let guards = outer; + const result: Program.Statement[] = []; + for (const item of body) { + const converted = statement(item, labels, guards); + if (converted === undefined) return undefined; + result.push(...converted); + // `if (other === null) return;` guards the rest of the block. + if (item.kind === "if") { + if (exits(resolve(item.consequent))) guards = union(guards, proves(item.test, false)); + if (exits(resolve(item.alternate))) guards = union(guards, proves(item.test, true)); + } + } + return result; + }; + const depth = (label: number, labels: readonly number[]): number => labels.length - 1 - labels.lastIndexOf(label); + const statement = (node: Statement, labels: readonly number[], guards: Guards): Program.Statement[] | undefined => { + switch (node.kind) { + case "set": { + const value = expression(node.value, guards, false); + return value && [{ kind: "set", local: localIndex(node.local), value }]; + } + case "declare": + for (const item of node.locals) localIndex(item); + return []; + case "store": { + const value = expression(node.value, guards, false); + return value && [{ kind: "store", column: node.column, value }]; + } + case "stamp": case "next": + return [node]; + case "if": { + const test = expression(node.test, guards, true); + const consequent = block(resolve(node.consequent), labels, union(guards, proves(node.test, true))); + const alternate = block(resolve(node.alternate), labels, union(guards, proves(node.test, false))); + return test && consequent && alternate && [{ kind: "if", test, consequent, alternate }]; + } + case "block": case "loop": { + const body = block(resolve(node.body), [...labels, node.label], guards); + return body && [{ kind: node.kind, body }]; + } + case "branch": return [{ kind: "branch", depth: depth(node.label, labels) }]; + case "intrinsic": { + const operands = all(node.operands, lanes => all(lanes, lane => expression(lane, guards, false))); + const body = block(resolve(node.body), labels, guards); + intrinsics = Math.max(intrinsics, node.id + 1); + return operands && body && [{ kind: "intrinsic", id: node.id, name: node.name, intrinsic: node.intrinsic, operands, body }]; + } + case "branchIf": { + const test = expression(node.test, guards, true); + return test && [{ kind: "branchIf", depth: depth(node.label, labels), test }]; + } + case "probe": { + const values = all(node.values, item => expression(item, guards, false)); + return values && [{ kind: "probe", list: handleFor(node.list), first: handleFor(node.first), probe: node.probe, values }]; + } + case "listRow": { + const index = expression(node.index, guards, false); + return index && [{ kind: "listRow", row: handleFor(node.row), list: handleFor(node.list), index }]; + } + case "lookup": { + const entity = expression(node.entity, guards, false); + const handle = handles.get(node.local) ?? handles.size; + handles.set(node.local, handle); + return entity && [{ kind: "find", handle, lookup: node.lookup, entity }]; + } + default: return undefined; + } + }; + const body = block(statements, [], none); + if (body === undefined) return null; + const counted = { ...intrinsics === 0 ? {} : { intrinsics }, ...accumulators.length === 0 ? {} : { reductions: accumulators.map(localIndex) } }; + if (listed) Object.assign(counted, { up: true }); + if (nameOf === undefined) return { locals: locals.size, handles: handles.size, ...counted, body }; + const names: string[] = []; + for (const [original, dense] of locals) names[dense] = nameOf(original); + return { locals: locals.size, handles: handles.size, names, ...counted, body }; +}; diff --git a/packages/ecs/src/implementation/compiler/scalar.ts b/packages/ecs/src/implementation/compiler/scalar.ts new file mode 100644 index 000000000..fd214f000 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/scalar.ts @@ -0,0 +1,46 @@ +import type { Node } from 'estree'; +import { componentRead, fieldRead } from './columns.js'; +import { fail } from './failure.js'; +import type { Scope } from './scope.js'; +import { local, type Expression } from './ir.js'; +import { snapshotLeaf } from './snapshot.js'; +import { describeValue, type Value } from './value.js'; + +/** The `rowLeaves` index of a leaf bodies read of a lookup's rows, listed once. */ +const rowLeafIndex = (unit: Scope["unit"], lookup: number, read: number, path: readonly (number | string)[]): number => { + const text = JSON.stringify([lookup, read, path]); + const index = unit.rowLeaves.indexOf(text); + if (index >= 0) return index; + unit.rowLeaves.push(text); + return unit.rowLeaves.length - 1; +}; + +/** A component in `write` is readable too. */ +export const requireReadable = (node: Node, scope: Scope, component: string): void => { + const { signature } = scope.unit; + if (!signature.read.has(component) && !signature.write.has(component)) fail(node, `${component} is not in components read or write`); + scope.unit.used.read.add(component); +}; + +/** A value used as a number or boolean. A component of unknown shape is read as a scalar. */ +export const scalar = (value: Value, node: Node, scope: Scope): Expression => { + switch (value.kind) { + case "scalar": case "dynamic": case "found": + return value.expression; + case "component": + if (value.up === true) return componentRead(scope.unit, value); + requireReadable(node, scope, value.component); + return fieldRead(scope.unit, value.component, value.path); + case "snapshot": + return local(snapshotLeaf(scope.unit, value.snapshot, value.path)); + // A row tests as found, so a unique index's result compares with null as a lookup's does. + case "indexed": + return { kind: "rowFound", row: value.first, lookup: value.lookup }; + case "indexRow": + return { kind: "rowFound", row: value.row, lookup: value.lookup }; + case "rowRead": + return { kind: "rowLeaf", row: value.row, lookup: value.lookup, leaf: rowLeafIndex(scope.unit, value.lookup, value.read, value.path) }; + default: + return fail(node, `expected a number or boolean, not ${describeValue(value)}`); + } +}; diff --git a/packages/ecs/src/implementation/compiler/scope.ts b/packages/ecs/src/implementation/compiler/scope.ts new file mode 100644 index 000000000..3da2e9783 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/scope.ts @@ -0,0 +1,27 @@ +import type { Module } from './module-scope.js'; +import type { Unit } from './unit.js'; +import type { Value } from './value.js'; + +export type Local = { readonly value: Value; readonly mutable: boolean }; + +/** The labels `break` and `continue` branch to. */ +export type Loop = { readonly exit: number; readonly next: number }; + +/** A lexical scope. A helper's scope has no parent, so it sees only its parameters and its module's scope, `module`. */ +export type Scope = { + readonly unit: Unit; + readonly module: Module; + readonly locals: Map; + readonly parent: Scope | undefined; + readonly loop: Loop | undefined; + readonly helper: boolean; +}; + +export const rootScope = (unit: Unit, helper: boolean, module: Module = unit.module): Scope => + ({ unit, module, locals: new Map(), parent: undefined, loop: undefined, helper }); + +export const childScope = (scope: Scope, loop: Loop | undefined = scope.loop): Scope => + ({ unit: scope.unit, module: scope.module, locals: new Map(), parent: scope, loop, helper: scope.helper }); + +export const findLocal = (scope: Scope | undefined, name: string): Local | undefined => + scope === undefined ? undefined : scope.locals.get(name) ?? findLocal(scope.parent, name); diff --git a/packages/ecs/src/implementation/compiler/serialize.ts b/packages/ecs/src/implementation/compiler/serialize.ts new file mode 100644 index 000000000..29c9a1f4c --- /dev/null +++ b/packages/ecs/src/implementation/compiler/serialize.ts @@ -0,0 +1,10 @@ +import { printNumber } from './print.js'; + +/** Data as a JS literal; unlike JSON, it keeps NaN, infinities and -0. */ +export const serialize = (value: unknown): string => { + if (typeof value === "number") return printNumber(value); + if (typeof value === "string" || typeof value === "boolean" || value === null) return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(serialize).join(", ")}]`; + if (typeof value === "object") return `{ ${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}: ${serialize(item)}`).join(", ")} }`; + return "undefined"; +}; diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts new file mode 100644 index 000000000..4c2fa5a2f --- /dev/null +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -0,0 +1,170 @@ +import type { Expression, ObjectExpression, Property, SpreadElement, Pattern } from 'estree'; +import type { Reduction } from '../../interface/resource/reduction.js'; +import { isReduction, reductionOperators } from '../../interface/registry/reduction.js'; +import { fail } from './failure.js'; +import type { Lists } from './lists.js'; +import type { ModuleScope } from './module-scope.js'; + +/** + * A lookup by entity, or through an index (`index`), whose calls take a key and give a list of rows. A lookup by + * entity that `watches` declares `changed`: its result has `changed` after what it reads. + */ +export type Lookup = { readonly name: string; readonly read: readonly string[]; readonly index: boolean; readonly watches: boolean }; + +/** The reference rows run down, and what the context's `up` reads of the entity it names. */ +export type Up = { readonly through: string; readonly read: readonly string[] }; + +/** A resource the kernel reduces into, by its module-scope binding name. */ +export type Reduced = { readonly resource: string; readonly operator: Reduction }; + +/** + * The parts of a kernel declaration the compiler needs. Component and resource keys are expressions of the kernel's + * module: its module-scope bindings, or, for a key only another module's list names, that list indexed (`lists.ts`). + */ +export type Signature = { + readonly read: ReadonlySet; + readonly write: ReadonlySet; + readonly resources: readonly string[]; + readonly reduced: readonly Reduced[]; + readonly lookups: readonly Lookup[]; + readonly up: Up | null; + /** Whether its rows run in an index's order, as a list, as `up`'s do. */ + readonly ordered: boolean; + readonly input: boolean; + /** Whether it filters on `added` or `changed` components, or on changed resources. */ + readonly filtered: boolean; +}; + +const entry = (name: string, value: T): readonly [string, T] => [name, value]; + +const propertyName = (property: Property): string => { + if (!property.computed && property.key.type === "Identifier") return property.key.name; + if (property.key.type === "Literal" && typeof property.key.value === "string") return property.key.value; + return fail(property.key, "kernel declaration keys must be plain names"); +}; + +const properties = (node: Expression | Pattern, what: string): readonly (readonly [string, Property])[] => { + if (node.type !== "ObjectExpression") return fail(node, `${what} must be an object literal in a kernel declaration`); + return node.properties.map((property: Property | SpreadElement) => property.type === "SpreadElement" + ? fail(property, `${what} can't use spread in a kernel declaration`) + : entry(propertyName(property), property)); +}; + +/** Keys a kernel declaration lists, as `Lists.names` reads them; `what` names the list in a failure. */ +const identifiers = (node: Expression | Pattern, what: string, lists: Lists): readonly string[] => { + const names = lists.names(node); + return "error" in names ? fail(node, `${what}: ${names.error}`) : names; +}; + +const keyLists = (node: Expression | Pattern, what: string, allowed: readonly string[], lists: Lists): ReadonlyMap => + new Map(properties(node, what).map(([name, property]) => allowed.includes(name) + ? entry(name, identifiers(property.value, `${what}.${name}`, lists)) + : fail(property, `${what}.${name} is not allowed in a kernel declaration`))); + +/** `reduce: { [resource]: "operator", ... }`, with resources bound at module scope. */ +const reductions = (node: Expression | Pattern, module: ModuleScope): readonly Reduced[] => { + if (node.type !== "ObjectExpression") return fail(node, "reduce must be an object literal in a kernel declaration"); + return node.properties.map((property): Reduced => { + if (property.type === "SpreadElement" || !property.computed || property.key.type !== "Identifier" || !module.has(property.key.name)) { + return fail(property, "reduce names each resource as a computed key bound at module scope, as in { [total]: \"+\" }"); + } + const { value } = property; + const operator = value.type === "Literal" ? value.value : undefined; + if (!isReduction(operator)) return fail(value, `a resource is reduced with one of ${reductionOperators.map(item => JSON.stringify(item)).join(", ")}`); + return { resource: property.key.name, operator }; + }).map((entry, index, all) => all.findIndex(other => other.resource === entry.resource) === index ? entry : fail(node, `${entry.resource} is reduced twice`)); +}; + +/** What kind of kernel a `System.create` declaration compiles to, or null when it isn't a kernel. */ +export const kernelOf = (declaration: ObjectExpression): "cpu" | "gpu" | null => { + for (const property of declaration.properties) { + if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "kernel") { + const { value } = property; + if (value.type === "Literal" && (value.value === "cpu" || value.value === "gpu")) return value.value; + return fail(value, `kernel must be the literal "cpu" or "gpu"`); + } + } + return null; +}; + +/** Reads and validates a kernel declaration; `lists` reads its lists of keys. */ +export const signature = (declaration: ObjectExpression, module: ModuleScope, lists: Lists): Signature => { + let components: ReadonlyMap | undefined; + let resources: readonly string[] = []; + let reduced: readonly Reduced[] = []; + let changedResources: readonly string[] = []; + let input = false; + let up: Up | null = null; + let ordered = false; + const lookups: Lookup[] = []; + for (const [name, property] of properties(declaration, "a kernel declaration")) { + switch (name) { + case "components": + components = keyLists(property.value, "components", ["read", "write", "has", "not", "added", "changed"], lists); + break; + case "resources": + for (const [list, value] of properties(property.value, "resources")) { + // A condition in `is` is readable too; one in `not` is only checked before the kernel runs. + if (list === "read" || list === "is") resources = [...resources, ...identifiers(value.value, `resources.${list}`, lists)]; + else if (list === "changed") changedResources = identifiers(value.value, "resources.changed", lists); + else if (list === "not") continue; + else fail(value, `resources.${list} is not allowed in a kernel declaration`); + } + break; + case "reduce": + reduced = reductions(property.value, module); + break; + case "lookup": + for (const [lookup, value] of properties(property.value, "lookup")) { + // The index is a value the world resolves; only that a lookup goes through one changes how it compiles. + const fields = properties(value.value, `lookup.${lookup}`); + const index = fields.some(([field]) => field === "index"); + const listed = fields.filter(([field]) => field !== "index"); + const read = listed.flatMap(([field, item]) => { + if (index ? field !== "read" && field !== "changed" : field !== "read" && field !== "has" && field !== "not" && field !== "changed") return fail(item, `lookup.${lookup}.${field} is not allowed in a kernel declaration`); + // An index lookup's `changed: true` filters the system's runs, which the world does before calling it. + if (index && field === "changed" && item.value.type === "Literal" && item.value.value === true) return []; + const names = identifiers(item.value, `lookup.${lookup}.${field}`, lists); + return field === "read" ? names : []; + }); + lookups.push({ name: lookup, read, index, watches: !index && listed.some(([field]) => field === "changed") }); + } + break; + case "up": { + let through: string | undefined; + let read: readonly string[] = []; + for (const [field, value] of properties(property.value, "up")) { + if (field === "through") { + const node = value.value; + through = node.type === "Identifier" && module.has(node.name) ? node.name : fail(node, "up.through is an identifier bound at module scope"); + } + else if (field === "read") read = identifiers(value.value, "up.read", lists); + else fail(value, `up.${field} is not allowed in a kernel declaration`); + } + up = { through: through ?? fail(property, "up needs through"), read }; + break; + } + case "input": + input = true; + break; + // The index is a value the world resolves; only that rows run as a list changes how it compiles. + case "order": + ordered = true; + break; + case "name": case "kernel": case "before": case "after": case "during": + break; + default: + fail(property, `${name} is not allowed in a kernel declaration`); + } + } + if (components === undefined) return fail(declaration, "a kernel needs components"); + for (const { resource } of reduced) { + if (resources.includes(resource) || changedResources.includes(resource)) fail(declaration, `${resource} is reduced, so it can't also be read or filtered on`); + } + const write = components.get("write") ?? []; + const both = (components.get("read") ?? []).find(key => write.includes(key)); + if (both !== undefined) fail(declaration, `${both} is in both components read and write; write already makes it readable`); + const rowFiltered = (components.get("added")?.length ?? 0) + (components.get("changed")?.length ?? 0) > 0; + if (up !== null && ordered) fail(declaration, "a kernel can't use both up and order, which both decide the order of its rows"); + return { read: new Set(components.get("read")), write: new Set(write), resources, reduced, lookups, up, ordered, input, filtered: changedResources.length > 0 || rowFiltered }; +}; diff --git a/packages/ecs/src/implementation/compiler/snapshot.ts b/packages/ecs/src/implementation/compiler/snapshot.ts new file mode 100644 index 000000000..5a80927e4 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/snapshot.ts @@ -0,0 +1,34 @@ +import type { Path } from '../world/kernel.js'; +import { record } from './booleans.js'; +import type { Expression, Statement } from './ir.js'; +import { fresh, type Unit } from './unit.js'; + +/** + * A local bound to a component value of unknown shape. Each leaf the body uses becomes its own local, + * set where the snapshot was bound, so later writes to the component don't change it. + */ +export type Snapshot = { + readonly keyword: "const" | "let"; + readonly hint: string; + readonly read: (path: Path) => Expression; + readonly leaves: Map; +}; + +export const createSnapshot = (keyword: "const" | "let", hint: string, read: (path: Path) => Expression): Snapshot => + ({ keyword, hint, read, leaves: new Map() }); + +/** The local holding one leaf of `snapshot`. */ +export const snapshotLeaf = (unit: Unit, snapshot: Snapshot, path: Path): number => { + const identity = JSON.stringify(path); + const existing = snapshot.leaves.get(identity); + if (existing !== undefined) return existing.local; + const local = fresh(unit, snapshot.hint); + const value = snapshot.read(path); + record(unit, local, value); + snapshot.leaves.set(identity, { local, value }); + return local; +}; + +/** The leaves the body used, resolved after the body is compiled. */ +export const snapshotDeclaration = (snapshot: Snapshot) => (): readonly Statement[] => + [...snapshot.leaves.values()].map(leaf => ({ kind: "set", local: leaf.local, value: leaf.value, declare: snapshot.keyword })); diff --git a/packages/ecs/src/implementation/compiler/source-map.test.ts b/packages/ecs/src/implementation/compiler/source-map.test.ts new file mode 100644 index 000000000..008e941a4 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/source-map.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { decodeMappings, insertInto } from "./source-map.js"; + +describe("insertInto", () => { + const source = `const a = Symbol("a");\nexport const b = Symbol("b"); const after = 2;\n`; + const insertions = [ + { at: source.indexOf(`Symbol("a")`), text: "wrap(" }, + { at: source.indexOf(`Symbol("a")`) + `Symbol("a")`.length, text: `, "a")` }, + { at: source.indexOf(`Symbol("b")`), text: "wrap(" }, + { at: source.indexOf(`Symbol("b")`) + `Symbol("b")`.length, text: `, "b")` }, + { at: source.length, text: "\nfunction wrap(value) { return value; }\n" }, + ]; + + test("makes the insertions in order of offset, those at one offset in the order given", () => { + const { code } = insertInto("ab", [{ at: 1, text: "2" }, { at: 0, text: "0" }, { at: 1, text: "3" }], "x.ts"); + expect(code).toBe("0a23b"); + expect(insertInto(source, insertions, "src/keys.ts").code).toBe(`const a = wrap(Symbol("a"), "a");\nexport const b = wrap(Symbol("b"), "b"); const after = 2;\n\nfunction wrap(value) { return value; }\n`); + }); + + test("maps the start of every word of the source to itself, with a segment of its own", () => { + const { code, map } = insertInto(source, insertions, "src/keys.ts"); + expect({ sources: map.sources, sourcesContent: map.sourcesContent, version: map.version }).toEqual({ sources: ["src/keys.ts"], sourcesContent: [source], version: 3 }); + const generated = code.split("\n"); + const lines = decodeMappings(map.mappings); + const original = source.split("\n"); + for (let line = 0; line < 2; line++) { + for (const match of (original[line] ?? "").matchAll(/[\w$]+/g)) { + const word = match[0]; + const column = match.index; + // The same word, at the same occurrence along the generated line. + const occurrence = [...(original[line] ?? "").slice(0, column).matchAll(new RegExp(`\\b${word}\\b`, "g"))].length; + const at = [...(generated[line] ?? "").matchAll(new RegExp(`\\b${word}\\b`, "g"))][occurrence]?.index ?? -1; + const segment = lines[line]?.find(candidate => candidate.column === at); + expect([word, segment?.sourceLine, segment?.sourceColumn]).toEqual([word, line, column]); + } + } + // Inserted text starts no segment of its own. + expect(lines[0]?.some(segment => segment.column === generated[0]?.indexOf("wrap("))).toBe(false); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/source-map.ts b/packages/ecs/src/implementation/compiler/source-map.ts new file mode 100644 index 000000000..1598d812e --- /dev/null +++ b/packages/ecs/src/implementation/compiler/source-map.ts @@ -0,0 +1,134 @@ +import { locateIn, type Module } from './module-scope.js'; + +const base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** One mapping: a generated column and the source line and column it came from, all counted from 0. */ +type Segment = { readonly column: number; readonly sourceLine: number; readonly sourceColumn: number }; + +/** Decodes a source map's `mappings` into each generated line's segments that name a source position. */ +export const decodeMappings = (mappings: string): readonly (readonly Segment[])[] => { + const lines: Segment[][] = []; + let sourceLine = 0; + let sourceColumn = 0; + for (const line of mappings.split(";")) { + const segments: Segment[] = []; + let column = 0; + for (const text of line.split(",")) { + if (text === "") continue; + const values: number[] = []; + let value = 0; + let shift = 0; + for (const character of text) { + const digit = base64.indexOf(character); + value += (digit & 31) << shift; + if (digit & 32) shift += 5; + else { + values.push(value & 1 ? -(value >>> 1) : value >>> 1); + value = 0; + shift = 0; + } + } + column += values[0] ?? 0; + if (values.length < 4) continue; + sourceLine += values[2] ?? 0; + sourceColumn += values[3] ?? 0; + segments.push({ column, sourceLine, sourceColumn }); + } + lines.push(segments); + } + return lines; +}; + +/** Locates offsets in `code` at the source positions its map's `mappings` give: the nearest mapping at or before each. */ +export const locateThroughMap = (code: string, mappings: string): Module["locate"] => { + const generated = locateIn(code); + const lines = decodeMappings(mappings); + return offset => { + const { line, column } = generated(offset); + for (let index = line - 1; index >= 0; index--) { + const segments = lines[index] ?? []; + const found = [...segments].reverse().find(segment => index < line - 1 || segment.column <= column - 1); + if (found !== undefined) return { line: found.sourceLine + 1, column: found.sourceColumn + 1 + (index === line - 1 ? column - 1 - found.column : 0) }; + } + return { line, column }; + }; +}; + +/** A source map of one source, version 3, as a bundler takes one from a transform. */ +export type SourceMap = { readonly version: 3; readonly sources: string[]; readonly sourcesContent: string[]; readonly names: string[]; readonly mappings: string }; + +/** Text inserted before the character at offset `at`; insertions at one offset keep their order. */ +export type Insertion = { readonly at: number; readonly text: string }; + +/** Appends `value` as a base64 VLQ, the sign in its lowest bit. */ +const encode = (value: number, into: string[]): void => { + let rest = value < 0 ? (-value << 1) | 1 : value << 1; + do { + let digit = rest & 31; + rest >>>= 5; + if (rest > 0) digit |= 32; + into.push(base64[digit] ?? ""); + } while (rest > 0); +}; + +const word = /[\w$]/; + +/** + * `code` with `insertions` made, and its map back to `code` named `source`. Every character of `code` keeps its source + * position: a segment starts each line, each word or run of other characters, and what follows an insertion, so a + * position maps to its own column without interpolating. Inserted text maps to nothing of its own. + */ +export const insertInto = (code: string, insertions: readonly Insertion[], source: string): { readonly code: string; readonly map: SourceMap } => { + const ordered = insertions.map((insertion, order) => ({ insertion, order })).sort((a, b) => a.insertion.at - b.insertion.at || a.order - b.order); + const output: string[] = []; + const mappings: string[] = []; + let generatedColumn = 0; + let lineHasSegment = false; + let lastGeneratedColumn = 0; + let lastSourceLine = 0; + let lastSourceColumn = 0; + let sourceLine = 0; + let sourceColumn = 0; + let next = 0; + let inserted = true; + const newLine = (): void => { + mappings.push(";"); + generatedColumn = 0; + lastGeneratedColumn = 0; + lineHasSegment = false; + }; + const write = (text: string): void => { + output.push(text); + for (let k = 0; k < text.length; k++) { + if (text[k] === "\n") newLine(); + else generatedColumn++; + } + }; + for (let at = 0; at <= code.length; at++) { + while (next < ordered.length && ordered[next].insertion.at === at) { + write(ordered[next++].insertion.text); + inserted = true; + } + if (at === code.length) break; + const character = code[at] ?? ""; + if (character !== "\n" && (inserted || sourceColumn === 0 || word.test(character) !== word.test(code[at - 1] ?? ""))) { + if (lineHasSegment) mappings.push(","); + encode(generatedColumn - lastGeneratedColumn, mappings); + encode(0, mappings); + encode(sourceLine - lastSourceLine, mappings); + encode(sourceColumn - lastSourceColumn, mappings); + lastGeneratedColumn = generatedColumn; + lastSourceLine = sourceLine; + lastSourceColumn = sourceColumn; + lineHasSegment = true; + } + inserted = false; + write(character); + if (character === "\n") { + sourceLine++; + sourceColumn = 0; + } + else sourceColumn++; + } + return { code: output.join(""), map: { version: 3, sources: [source], sourcesContent: [code], names: [], mappings: mappings.join("") } }; +}; diff --git a/packages/ecs/src/implementation/compiler/spawns.ts b/packages/ecs/src/implementation/compiler/spawns.ts new file mode 100644 index 000000000..698662615 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/spawns.ts @@ -0,0 +1,114 @@ +import type { Expression, Node, Property, SpreadElement } from 'estree'; +import { children, declaredIn } from './estree.js'; +import { fail } from './failure.js'; +import type { ModuleScope } from './module-scope.js'; + +type Range = { readonly start: number; readonly end: number }; + +/** + * A `commands.spawn(components)({ ... })` or `commands.add(entity, components, { ... })` call with literal keys: key + * expressions as module-scope source, and source ranges of the entity, for an add, of the components and of the values + * in order. + */ +export type SpawnSite = Range & { + readonly kind: "spawn" | "add"; + readonly entity: Range | null; + readonly components: Range; + readonly keys: readonly string[]; + readonly values: readonly Range[]; +}; + +/** A plain, string or number key, which is never a component's, since component keys are symbols. */ +const plainKey = (property: Property | SpreadElement): boolean => + property.type === "Property" && property.kind === "init" && !property.method && (!property.computed || property.key.type === "Literal"); + +/** A key as a module-scope expression, or null when the property isn't a module constant's computed key. */ +const keyOf = (property: Property | SpreadElement, module: ModuleScope, declared: ReadonlySet): string | null => { + if (property.type !== "Property" || property.kind !== "init" || property.method || !property.computed) return null; + const { key } = property; + if (key.type !== "Identifier") return null; + return module.has(key.name) && !declared.has(key.name) ? key.name : null; +}; + +/** Whether an add's components are a list as written: an array literal, or a module constant of one or of `Archetype.create`. */ +const listed = (components: Expression, module: ModuleScope, declared: ReadonlySet): boolean => { + if (components.type === "ArrayExpression") return true; + if (components.type !== "Identifier" || declared.has(components.name)) return false; + const binding = module.get(components.name); + if (binding?.kind !== "const") return false; + const { init } = binding; + return init.type === "ArrayExpression" || (init.type === "CallExpression" && init.callee.type === "MemberExpression" && !init.callee.computed + && init.callee.property.type === "Identifier" && init.callee.property.name === "create" && init.arguments[0]?.type === "ArrayExpression"); +}; + +const plainKeyFailure = "a command's keys are its components' symbols, as [key]; a plain or string key is never a component's"; + +/** Where a body reaches its commands: a name bound to them, or a context parameter's `commands`. */ +const commandsOf = (parameter: Node | undefined): { readonly name: string; readonly viaContext: boolean } | null => { + if (parameter?.type === "Identifier") return { name: parameter.name, viaContext: true }; + if (parameter?.type !== "ObjectPattern") return null; + for (const property of parameter.properties) { + if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "commands") { + return property.value.type === "Identifier" ? { name: property.value.name, viaContext: false } : null; + } + } + return null; +}; + +/** + * The spawn and add sites in a main-thread system body whose values can pass positionally: `spawn(components)(values)` + * and `add(entity, components, values)` on the commands of the body's own context parameter, at index `context`, with + * an object literal of computed keys, each a module-scope constant. A body that redeclares the context or its commands anywhere is left alone, and so is a + * key it shadows. + */ +export const spawnSites = (body: Node, module: ModuleScope, context: number): { readonly commands: string; readonly sites: readonly SpawnSite[] } | null => { + if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return null; + const reached = commandsOf(body.params[context]); + if (reached === null) return null; + const declared = new Set(); + declaredIn(body.body, name => declared.add(name)); + if (declared.has(reached.name)) return null; + const isCommands = (node: Node): boolean => reached.viaContext + ? node.type === "MemberExpression" && !node.computed && !node.optional && node.object.type === "Identifier" && node.object.name === reached.name + && node.property.type === "Identifier" && node.property.name === "commands" + : node.type === "Identifier" && node.name === reached.name; + const sites: SpawnSite[] = []; + /** The method a call makes on the commands, and its arguments, or null. */ + const method = (node: Node): { readonly name: string; readonly arguments: readonly (Expression | SpreadElement)[] } | null => + node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && !node.optional + && isCommands(node.callee.object) && node.callee.property.type === "Identifier" + ? { name: node.callee.property.name, arguments: node.arguments } + : null; + const visit = (node: Node): void => { + if (node.type === "CallExpression" && !node.optional) { + const spawned = method(node.callee); + const called = method(node); + const spawn = spawned?.name === "spawn" && spawned.arguments.length === 1 && node.arguments.length === 1 ? spawned.arguments[0] : null; + const add = called?.name === "add" && called.arguments.length === 3 ? called.arguments : null; + const components = spawn ?? add?.[1] ?? null; + const values = spawn !== null ? node.arguments[0] : add?.[2]; + const entity = add?.[0] ?? null; + if (components !== null && components.type !== "SpreadElement" && entity?.type !== "SpreadElement" && values?.type === "ObjectExpression") { + // A spawn's values, or a list add's, are keyed by components; one component's object value has keys of its own. + const plain = values.properties.find(plainKey); + if (plain !== undefined && (spawn !== null || listed(components, module, declared))) return fail(plain.type === "Property" ? plain.key : plain, plainKeyFailure); + const keys = plain === undefined ? values.properties.map(property => keyOf(property, module, declared)) : [null]; + if (keys.every((key): key is string => key !== null)) { + sites.push({ + kind: spawn !== null ? "spawn" : "add", + start: node.start, + end: node.end, + entity: entity === null ? null : { start: entity.start, end: entity.end }, + components: { start: components.start, end: components.end }, + keys, + values: values.properties.flatMap(property => property.type === "Property" ? [{ start: property.value.start, end: property.value.end }] : []), + }); + return; + } + } + } + for (const child of children(node)) visit(child); + }; + visit(body.body); + return { commands: reached.viaContext ? `${reached.name}.commands` : reached.name, sites }; +}; diff --git a/packages/ecs/src/implementation/compiler/stamps.ts b/packages/ecs/src/implementation/compiler/stamps.ts new file mode 100644 index 000000000..d1c2fa78b --- /dev/null +++ b/packages/ecs/src/implementation/compiler/stamps.ts @@ -0,0 +1,24 @@ +import type { Statement } from './ir.js'; +import { resolve } from './out.js'; + +const exits = (statement: Statement): boolean => { + switch (statement.kind) { + case "next": return true; + case "if": return resolve(statement.consequent).some(exits) || resolve(statement.alternate).some(exits); + case "block": case "loop": return resolve(statement.body).some(exits); + default: return false; + } +}; + +/** + * Chunk columns stamped on every row: by a top-level stamp that no earlier `next` can skip. Every row in + * `[start, end)` runs the body, so these chunks can be stamped once per call instead of per row. + */ +export const unconditionalChunks = (rows: readonly Statement[]): ReadonlySet => { + const chunks = new Set(); + for (const statement of rows) { + if (exits(statement)) break; + if (statement.kind === "stamp") chunks.add(statement.chunks); + } + return chunks; +}; diff --git a/packages/ecs/src/implementation/compiler/statement.ts b/packages/ecs/src/implementation/compiler/statement.ts new file mode 100644 index 000000000..26bbe53f7 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/statement.ts @@ -0,0 +1,102 @@ +import type { DoWhileStatement, Expression, ForStatement, Node, WhileStatement } from 'estree'; +import { assign, update } from './assign.js'; +import { bind } from './bind.js'; +import { fail } from './failure.js'; +import { not } from './ir.js'; +import type { Out } from './out.js'; +import { scalar } from './scalar.js'; +import { childScope, type Scope } from './scope.js'; +import { label } from './unit.js'; + +/** The label of the JS row loop; `next` continues it. */ +export const rowLabel = "rows"; + +const expressionStatement = (node: Expression, scope: Scope, out: Out): void => { + switch (node.type) { + case "AssignmentExpression": return assign(node, scope, out); + case "UpdateExpression": return update(node, scope, out); + case "SequenceExpression": return node.expressions.forEach(item => expressionStatement(item, scope, out)); + default: scope.unit.expression(node, scope, out); + } +}; + +/** Exits the loop's block when `node` is false. */ +const test = (node: Expression, exit: number, scope: Scope, out: Out): void => { + out.push({ kind: "branchIf", label: exit, test: not(scalar(scope.unit.expression(node, scope, out), node, scope)) }); +}; + +/** + * Every loop compiles to `block exit { loop repeat { test; block next { body } update; branch repeat } }`, so a test + * that emits statements is re-evaluated each iteration, `break` exits `exit`, and `continue` exits `next` and still + * runs the update. + */ +const loop = (node: ForStatement | WhileStatement | DoWhileStatement, scope: Scope, out: Out): void => { + const outer = childScope(scope); + if (node.type === "ForStatement" && node.init) { + if (node.init.type === "VariableDeclaration") statement(node.init, outer, out); + else expressionStatement(node.init, outer, out); + } + const unit = scope.unit; + const exit = label(unit); + const repeat = label(unit); + const next = label(unit); + const head: Out = []; + const tail: Out = []; + if (node.type !== "DoWhileStatement" && node.test) test(node.test, exit, outer, head); + const body: Out = []; + statement(node.body, childScope(outer, { exit, next }), body); + if (node.type === "ForStatement" && node.update) expressionStatement(node.update, outer, tail); + if (node.type === "DoWhileStatement") test(node.test, exit, outer, tail); + out.push({ + kind: "block", label: exit, body: [{ + kind: "loop", label: repeat, + body: [...head, { kind: "block", label: next, body }, ...tail, { kind: "branch", label: repeat }], + }], + }); +}; + +/** Compiles a statement of the kernel subset. */ +export const statement = (node: Node, scope: Scope, out: Out): void => { + switch (node.type) { + case "VariableDeclaration": + if (node.kind !== "const" && node.kind !== "let") return fail(node, `use const or let, not ${node.kind}`); + for (const declarator of node.declarations) { + if (!declarator.init) return fail(declarator, "locals need an initializer"); + const value = scope.unit.expression(declarator.init, scope, out); + bind(declarator.id, value, node.kind, scope, out); + } + return; + case "ExpressionStatement": + return expressionStatement(node.expression, scope, out); + case "BlockStatement": { + const inner = childScope(scope); + for (const item of node.body) statement(item, inner, out); + return; + } + case "IfStatement": { + const condition = scalar(scope.unit.expression(node.test, scope, out), node.test, scope); + const consequent: Out = []; + const alternate: Out = []; + statement(node.consequent, childScope(scope), consequent); + if (node.alternate) statement(node.alternate, childScope(scope), alternate); + out.push({ kind: "if", test: condition, consequent, alternate }); + return; + } + case "ForStatement": case "WhileStatement": case "DoWhileStatement": + return loop(node, scope, out); + case "BreakStatement": case "ContinueStatement": + if (node.label) return fail(node, "labels are not in the kernel subset"); + if (scope.loop === undefined) return fail(node, `${node.type === "BreakStatement" ? "break" : "continue"} outside a loop`); + out.push({ kind: "branch", label: node.type === "BreakStatement" ? scope.loop.exit : scope.loop.next }); + return; + case "ReturnStatement": + if (scope.helper) return fail(node, "a helper can only return at its end"); + if (node.argument) return fail(node, "a kernel returns nothing; return ends the current row"); + out.push({ kind: "next" }); + return; + case "EmptyStatement": + return; + default: + return fail(node, `${node.type} is not in the kernel subset`); + } +}; diff --git a/packages/ecs/src/implementation/compiler/subset.md b/packages/ecs/src/implementation/compiler/subset.md new file mode 100644 index 000000000..478f11bd2 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/subset.md @@ -0,0 +1,82 @@ +# Kernel subset + +What a kernel body (`kernel: "cpu"` or `"gpu"`) may contain. The build rejects anything else with a message at the +offending code. It is plain TypeScript; types are stripped before compiling. + +``` +body = "(" [row ["," context ["," commands]]] ")" "=>" (block | expression) | "function" "(" ... ")" block +row = identifier | pattern // parameters may be destructured +statement = declaration | (assignment | update | call) {"," (assignment | update | call)} | if | loop | break | continue + | return | block | ";" +declaration = ("const" | "let") pattern "=" expression { "," pattern "=" expression } +pattern = identifier | "[" pattern { "," pattern } "]" | "{" field [":" pattern] { "," ... } "}" +assignment = target ("=" | "+=" | "-=" | "*=" | "/=" | "%=" | "**=" | "&=" | "|=" | "^=" | "<<=" | ">>=" | ">>>=") expression +target = let-local | pattern of let-locals | "row[" component "]" +update = ("++" | "--") target | target ("++" | "--") +if = "if (" expression ")" statement ["else" statement] +loop = "for (" [declaration | assignment] ";" [expression] ";" [assignment | update] ")" statement + | "while (" expression ")" statement | "do" statement "while (" expression ")" +return = "return" // ends the current row +expression = literal | identifier | tuple | struct | unary | binary | logical | conditional | member | call +literal = number | boolean | "null" | "undefined" | "NaN" | "Infinity" +tuple = "[" expression { "," expression } "]" +struct = "{" name ":" expression { "," name ":" expression } "}" +unary = ("-" | "+" | "!" | "~") expression +binary = expression ("+" | "-" | "*" | "/" | "%" | "**" | "<" | ">" | "<=" | ">=" | "==" | "!=" | "===" | "!==" + | "&" | "|" | "^" | "<<" | ">>" | ">>>") expression +logical = expression ("&&" | "||" | "??") expression +conditional = expression "?" expression ":" expression +member = expression "[" index "]" | expression "." name | expression "?." name +call = Math-function | helper | lookup | command | reducer +``` + +## Values + +- **Numbers and booleans** are scalars. **Tuples and structs** of them are values with a fixed shape: they are + immutable, compiled to one local per leaf, and never allocate. A `let` takes a whole new value; its elements can't be + assigned. +- **The body is `(row, context)`,** and either may be destructured, as in `(row, { resources, lookup, reduce })`. +- **`row[component]`** reads a component in `components.read` or `components.write`, and writes one in + `components.write`, always whole. `row.entity` is the row's entity. +- **`context.resources[resource]`** reads a resource in `resources.read`, and `context.input` the system's input. + Kernels don't write resources; they reduce into them. +- **Declarations list exactly what the body uses.** Every component in `components.read`, `components.write` and a + lookup's `read` must be read or written as listed, every resource in `resources.read` read, and every one in + `reduce` contributed to; a component only required belongs in `has`. +- **Indexes** are literals, field names, or constants and locals whose value is a number or string literal. Tuples and + structs are indexed only statically; resources and the input may be indexed at runtime. +- **Shapes are known at build time.** A local holding a component value takes its shape from the leaves the body uses + or assigns. A tuple or struct component value written or contributed whole with none of its leaves used, as in `row[a] = row[b]`, + fails at registration: destructure it or index its fields. + +## Calls + +- **`Math`:** `abs sign floor ceil round trunc min max sqrt cbrt hypot exp expm1 log log1p log2 log10 pow sin cos tan + asin acos atan atan2 sinh cosh tanh asinh acosh atanh fround imul clz32`, and its constants. +- **Helpers:** functions and constants of any module the build resolves to a file, the kernel's own included, the + package's math types among them. Each call is inlined in its own module's scope. A helper's body is in the same + subset; it may `return` early inside `if`, and must end with `return `. No recursion, no closures, no default + or rest parameters, no spread. +- **Lookups:** `context.lookup.name(entity)` returns the other entity's values in that lookup's `read`, or `null`. +- **Reductions:** `context.reduce[resource](value)` contributes a value to a resource in `reduce`; see `design.md`. +- **Commands:** `context.commands.spawn(components)({ [component]: value, ... })`, `despawn(entity)`, + `add(entity, component, value)` or `add(entity, components, { ... })`, and `remove(entity, component)` or + `remove(entity, components)`, with component keys bound at module scope. A list of components is an array literal of + them, or a constant holding one or `Archetype.create` of one, in the kernel's module or imported, also through + re-exports and namespaces, its keys read in the scope of the module that declares it; a component it lists that a + command gives no value starts at its start. A list the build can't read, such as one computed when its module runs, + fails the build at the command. `add` and `remove` take a name as one key when its definition is `Symbol(...)`, as a + list when it's a list, and otherwise fail the build with the reason; a name from a package external to the build, + which the build can't read, is one key, and registration rejects a list there. The spawner is called where it's made. + +## GPU kernels + +Also no lookups, change filters, commands, reductions or loops, and only 4-byte scalar values: f32, +i32, u32, and booleans in a `"storage"` layout. Results follow WGSL where it leaves JS results indeterminate (NaN and +infinities). + +## Not in the subset + +`var`, classes, closures and function expressions, `this`, `new`, `delete`, `typeof`, `in`, `instanceof`, strings +other than as keys, template literals, regular expressions, `try`, `throw`, `switch`, labels, generators, `async`, +spread, holes, optional calls, and module state other than constants. diff --git a/packages/ecs/src/implementation/compiler/terminal.ts b/packages/ecs/src/implementation/compiler/terminal.ts new file mode 100644 index 000000000..a0dbde69f --- /dev/null +++ b/packages/ecs/src/implementation/compiler/terminal.ts @@ -0,0 +1,49 @@ +import type { Node, Pattern } from 'estree'; +import { children, declaredIn } from './estree.js'; + +/** Whether `node`, a child of `parent`, names a property or label, or declares a binding, rather than referring to one. */ +const isName = (node: Node, parent: Node | undefined): boolean => { + switch (parent?.type) { + case "MemberExpression": return parent.property === node && !parent.computed; + case "Property": return parent.key === node && !parent.computed && !parent.shorthand; + case "MethodDefinition": case "PropertyDefinition": return parent.key === node && !parent.computed; + case "LabeledStatement": case "BreakStatement": case "ContinueStatement": return true; + case "VariableDeclarator": return parent.id === node; + default: return false; + } +}; + +/** Whether a destructuring of the context binds plain names other than `commands`, with no rest. */ +const destructuresOthers = (pattern: Pattern): boolean => + pattern.type === "ObjectPattern" && pattern.properties.every(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name !== "commands"); + +/** Whether a name is used anywhere in `node` as a reference: `arguments` or `eval` could reach the context. */ +const refers = (node: Node, name: string, parent?: Node): boolean => + (node.type === "Identifier" && node.name === name && !isName(node, parent)) || children(node).some(child => refers(child, name, node)); + +/** + * Whether a main-thread system's body provably never reaches its context's `commands`, so the system is terminal + * where its declaration allows: its context, the parameter at `context`, is absent, destructured by plain names other + * than `commands` with no rest, or a name the body doesn't redeclare and uses only as `name.property` for other + * properties; and the body never refers to `arguments` or `eval`. A body it can't follow may reach them. + */ +export const neverReachesCommands = (body: Node, context: number): boolean => { + if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return false; + if (refers(body.body, "eval") || (body.type === "FunctionExpression" && refers(body.body, "arguments"))) return false; + const parameter = body.params[context]; + if (parameter === undefined) return body.params.every(each => each.type !== "RestElement"); + if (parameter.type !== "Identifier") return destructuresOthers(parameter); + const { name } = parameter; + let redeclared = false; + declaredIn(body.body, declared => { if (declared === name) redeclared = true; }); + if (redeclared) return false; + const reaches = (node: Node, parent: Node | undefined): boolean => { + if (node.type === "Identifier" && node.name === name && !isName(node, parent)) { + return !(parent?.type === "MemberExpression" && parent.object === node && !parent.computed + && parent.property.type === "Identifier" && parent.property.name !== "commands"); + } + return children(node).some(child => reaches(child, node)); + }; + return !reaches(body.body, body); +}; diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts new file mode 100644 index 000000000..d4e23eabf --- /dev/null +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -0,0 +1,1421 @@ +/// +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { describe, expect, test } from 'vitest'; +import { parseAst } from 'vite'; +import { chunkShift, type KernelDescription, type KernelModule, type Program } from '../world/kernel.js'; +import { transformKernels } from './transform.js'; +import { createLinker, type LinkHost } from './link.js'; +import { packageIndex } from './paths.js'; +import { locateThroughMap } from './source-map.js'; +import { estreeOf } from './estree.js'; +import { Mat4x4, Quat, Vec3 } from '../../interface/math/index.js'; +import { generateWgsl } from '../world/wgsl.js'; +import { identityOf } from '../world/reductions.js'; +import type { WasmColumn } from '../world/wasm.js'; + +const parse = (code: string) => estreeOf(parseAst(code)); + +const header = `import { System } from "ecs"; +const position = "position"; +const velocity = "velocity"; +const mass = "mass"; +const alive = "alive"; +const score = "score"; +const delta = "delta"; +const target = "target"; +`; + +/** Modules by absolute path, for tests of imports: real files for anything not in `files`, and "ecs" for the package. */ +const host = (files: { readonly [file: string]: string }): LinkHost => ({ + resolve: async (specifier, importer) => { + if (specifier === "ecs") return packageIndex; + if (specifier === "external") return { error: "external is external to the build", unreadable: true }; + if (!specifier.startsWith(".")) return { error: `${specifier} can't be resolved from ${importer}` }; + const path = resolve(dirname(importer), specifier); + const typescript = path.replace(/\.js$/, ".ts"); + return files[typescript] !== undefined || (files[path] === undefined && existsSync(typescript)) ? typescript : path; + }, + load: async file => { + const code = files[file] ?? await readFile(file, "utf8"); + if (!file.endsWith(".ts")) return { code }; + const { transformWithOxc } = await import("vite"); + const result = await transformWithOxc(code, file, { lang: "ts", sourcemap: true }); + return { code: result.code, locate: result.map === undefined ? undefined : locateThroughMap(result.code, result.map.mappings) }; + }, +}); + +const transform = (source: string, files: { readonly [file: string]: string } = {}) => transformKernels(`${header}${source}`, "/test.js", parse, + async specifier => specifier === "ecs", index => JSON.stringify(`kernel:${index}`), createLinker(host(files), parse)); + +type Compiled = KernelDescription & KernelModule; + +/** + * Compiles `source`, which assigns its kernel system to `kernel`: the attached description plus its kernel module's + * `create`. `imported` gives the values of the bindings `source` imports, since its imports aren't evaluated. + */ +const compile = async (source: string, files: { readonly [file: string]: string } = {}, imported: { readonly [name: string]: unknown } = {}): Promise => { + const result = await transform(source, files); + if (result === null || "failure" in result) throw new Error(result === null ? "no kernel" : result.failure.message); + const module = result.code.split("\n").filter(line => !line.startsWith("import ")).join("\n"); + const evaluate = new Function("System", "attachKernel$", "Archetype", ...Object.keys(imported), `${module}\nreturn kernel;`); + const description: KernelDescription = evaluate({ create: (declaration: unknown, body: unknown) => ({ declaration, body }) }, (_: unknown, attached: unknown) => attached, { create: (list: unknown) => list }, ...Object.values(imported)); + const index = Number(description.url.slice("kernel:".length)); + const kernelModule: KernelModule = new Function(`${result.kernels[index]?.replace("export const create =", "const create =")}\nreturn { create };`)(); + expect(kernelModule.create.toString()).not.toMatch(/\b(position|velocity|mass|alive|score|delta|target)\b/); + return { ...description, ...kernelModule }; +}; + +const failure = async (source: string, files: { readonly [file: string]: string } = {}): Promise => { + const result = await transform(source, files); + return result !== null && "failure" in result ? result.failure.message : "compiled"; +}; + +type Fields = { readonly [component: string]: { readonly [path: string]: readonly number[] } }; +type Call = readonly unknown[]; +type Options = { + readonly resources?: readonly unknown[]; + readonly lookups?: readonly ((entity: number) => readonly unknown[] | null)[]; + readonly args?: unknown; + readonly booleans?: readonly string[]; + /** Whether the kernel has a `program`; when it does, the program must give the same results as the JS. */ + readonly program?: boolean; + /** The rows to run, `[start, end)`; all rows by default. */ + readonly range?: readonly [number, number]; + readonly interleaved?: { readonly [component: string]: { readonly stride: number; readonly offsets: { readonly [path: string]: number } } }; +}; + +const tick = 7; + +type Scalar = number | boolean; +type Signal = { readonly kind: "normal" } | { readonly kind: "branch"; readonly depth: number } | { readonly kind: "next" }; +const normal: Signal = { kind: "normal" }; + +/** A reference interpreter for `Program`: JS number semantics, boolean typing of columns from `booleans`. */ +type Layout = { readonly strides: readonly number[]; readonly offsets: readonly number[] }; + +/** A reference interpreter for `Program`, indexing columns by the layout as the WASM generator does. */ +const interpret = (program: Program, columns: readonly (Float64Array | Uint32Array)[], booleans: readonly boolean[], layout: Layout, start: number, end: number, resources: readonly unknown[], args: unknown, + lookups: readonly ((entity: number) => readonly unknown[] | null)[], partials: Float64Array): void => { + const accumulators = program.reductions ?? []; + /** Accumulators carry from row to row, from their partials and back. */ + const carried = accumulators.map((_, k) => partials[k] ?? NaN); + const at = (column: number, row: number): number => row * (layout.strides[column] ?? 1) + (layout.offsets[column] ?? 0); + const scalarAt = (root: unknown, path: readonly (number | string)[]): Scalar => { + const value = path.reduce((item, key) => Reflect.get(Object(item), key), root); + if (typeof value !== "number" && typeof value !== "boolean") throw new Error(`not a scalar: ${String(value)}`); + return value; + }; + for (let row = start; row < end; row++) { + const locals: Scalar[] = new Array(program.locals).fill(0); + const handles: (readonly unknown[] | null)[] = new Array(program.handles ?? 0).fill(null); + const evaluate = (node: Program.Expression): Scalar => { + switch (node.kind) { + case "number": case "boolean": return node.value; + case "column": { + const value = columns[node.column]?.[at(node.column, row)] ?? NaN; + return booleans[node.column] === true ? value !== 0 : value; + } + case "resource": return scalarAt(resources[node.index], node.path); + case "args": return scalarAt(args, node.path); + case "local": return locals[node.local] ?? NaN; + case "unary": { + const operand = evaluate(node.operand); + switch (node.operator) { + case "-": return -Number(operand); + case "+": return Number(operand); + case "!": return !operand; + case "~": return ~Number(operand); + } + } + case "binary": { + const left = evaluate(node.left); + const right = evaluate(node.right); + const a = Number(left); + const b = Number(right); + switch (node.operator) { + case "+": return a + b; + case "-": return a - b; + case "*": return a * b; + case "/": return a / b; + case "%": return a % b; + case "**": return a ** b; + case "<": return a < b; + case ">": return a > b; + case "<=": return a <= b; + case ">=": return a >= b; + case "==": return a === b; + case "!=": return a !== b; + case "===": return left === right; + case "!==": return left !== right; + case "&": return a & b; + case "|": return a | b; + case "^": return a ^ b; + case "<<": return a << b; + case ">>": return a >> b; + case ">>>": return a >>> b; + } + } + case "logical": { + const left = evaluate(node.left); + return node.operator === "&&" ? (left ? evaluate(node.right) : left) : (left ? left : evaluate(node.right)); + } + case "conditional": return evaluate(node.test) ? evaluate(node.consequent) : evaluate(node.alternate); + case "found": return handles[node.handle] !== null; + case "lookupLeaf": { + // Not found reads NaN, as the WASM generator does. + const values = handles[node.handle]; + if (values === null || values === undefined) return NaN; + return scalarAt(values[node.read], node.path); + } + case "math": { + const method: unknown = Reflect.get(Math, node.name); + if (typeof method !== "function") throw new Error(`no Math.${node.name}`); + const result: unknown = Reflect.apply(method, Math, node.arguments.map(item => Number(evaluate(item)))); + return typeof result === "number" ? result : NaN; + } + case "lane": return evaluate(node.value); + case "upFound": case "upLeaf": throw new Error("programs reading up aren't interpreted here"); + case "listLength": case "listChanged": case "rowLeaf": case "rowEntity": throw new Error("lookups through indexes aren't interpreted here"); + } + }; + const execute = (statements: readonly Program.Statement[]): Signal => { + for (const statement of statements) { + const signal = step(statement); + if (signal.kind !== "normal") return signal; + } + return normal; + }; + const outward = (signal: Signal): Signal => signal.kind === "branch" ? (signal.depth === 0 ? normal : { kind: "branch", depth: signal.depth - 1 }) : signal; + const step = (statement: Program.Statement): Signal => { + switch (statement.kind) { + case "set": locals[statement.local] = evaluate(statement.value); return normal; + case "store": { const column = columns[statement.column]; if (column) column[at(statement.column, row)] = Number(evaluate(statement.value)); return normal; } + case "stamp": { + const column = columns[statement.column]; + const chunks = columns[statement.chunks]; + if (column) column[at(statement.column, row)] = tick; + if (chunks) chunks[row >> chunkShift] = tick; + return normal; + } + case "if": return execute(evaluate(statement.test) ? statement.consequent : statement.alternate); + case "block": return outward(execute(statement.body)); + case "loop": + for (let iteration = 0; iteration < 1e6; iteration++) { + const signal = execute(statement.body); + if (signal.kind === "branch" && signal.depth === 0) continue; + return signal.kind === "normal" ? normal : outward(signal); + } + throw new Error("runaway loop"); + case "branch": return { kind: "branch", depth: statement.depth }; + case "branchIf": return evaluate(statement.test) ? { kind: "branch", depth: statement.depth } : normal; + case "next": return { kind: "next" }; + case "intrinsic": return execute(statement.body); + case "find": { + const lookup = lookups[statement.lookup]; + if (lookup === undefined) throw new Error(`no lookup ${statement.lookup}`); + handles[statement.handle] = lookup(Number(evaluate(statement.entity))); + return normal; + } + case "probe": case "listRow": throw new Error("lookups through indexes aren't interpreted here"); + } + }; + accumulators.forEach((local, k) => { locals[local] = carried[k] ?? NaN; }); + execute(program.body); + accumulators.forEach((local, k) => { carried[k] = Number(locals[local]); }); + } + carried.forEach((value, k) => { partials[k] = value; }); +}; + +/** Padding in interleaved components, which kernels must never touch. */ +const padding = -12345; + +/** + * Runs a kernel over hand-built columns; `fields` maps a component to its leaves by JSON path. A component in + * `options.interleaved` stores its leaves in one array with that stride, at the given offsets per path; others are dense. + */ +const run = (description: Compiled, rows: number, fields: Fields, options: Options = {}) => { + const interleaved = options.interleaved ?? {}; + const layout: Layout = { + strides: description.columns.map(column => column.kind === "field" ? interleaved[String(column.component)]?.stride ?? 1 : 1), + offsets: description.columns.map(column => column.kind === "field" ? interleaved[String(column.component)]?.offsets[JSON.stringify(column.path)] ?? 0 : 0), + }; + const build = () => { + const shared = new Map(); + return description.columns.map((column, index) => { + if (column.kind === "entity") return Float64Array.from({ length: rows }, (_, row) => 100 + row); + if (column.kind === "changed") return new Uint32Array(rows); + if (column.kind === "chunks") return new Float64Array(Math.ceil(rows / 2 ** chunkShift)); + const component = String(column.component); + const values = fields[component]?.[JSON.stringify(column.path)] ?? new Array(rows).fill(0); + const stride = interleaved[component]?.stride; + if (stride === undefined) return Float64Array.from(values); + const array = shared.get(component) ?? new Float64Array(rows * stride).fill(padding); + shared.set(component, array); + values.forEach((value, row) => { array[row * stride + (layout.offsets[index] ?? 0)] = value; }); + return array; + }); + }; + const columns = build(); + const calls: Call[] = []; + // Records each command start as [kind, entity], and each value or component as [kind, entity, key index, value?]. + let current: readonly [number | "spawn", number] = ["spawn", 0]; + const commands = { + spawners: description.spawns.map(list => (...values: unknown[]) => { + current = ["spawn", 999]; + calls.push(current); + list.forEach((key, k) => calls.push([...current, key, values[k]])); + return 999; + }), + begin: (kind: number, entity: number) => { current = [kind, entity]; calls.push(current); }, + value: (key: number, value: unknown) => { calls.push([...current, key, value]); }, + component: (key: number) => { calls.push([...current, key]); }, + }; + const booleans = description.columns.map(column => column.kind === "field" && (options.booleans ?? []).includes(String(column.component))); + const [start, end] = options.range ?? [0, rows]; + const partials = Float64Array.from(description.reductions, reduction => identityOf(reduction.operator)); + const interpretedPartials = Float64Array.from(partials); + description.create(booleans, layout.strides, layout.offsets)(columns, start, end, options.resources ?? [], options.lookups ?? [], description.keys, options.args, commands, tick, partials, 0); + expect(description.program !== null).toBe(options.program ?? true); + if (description.program !== null) { + const interpreted = build(); + interpret(description.program, interpreted, booleans, layout, start, end, options.resources ?? [], options.args, options.lookups ?? [], interpretedPartials); + expect([...interpretedPartials]).toEqual([...partials]); + // Bit-identical, including NaN and -0. + expect(interpreted.map(column => [...column].map(value => Object.is(value, -0) ? "-0" : String(value)))) + .toEqual(columns.map(column => [...column].map(value => Object.is(value, -0) ? "-0" : String(value)))); + } + const index = (kind: string, component: string, path: readonly (number | string)[] = []) => description.columns.findIndex(column => + column.kind === kind && column.kind !== "entity" && column.component === component + && (column.kind !== "field" || JSON.stringify(column.path) === JSON.stringify(path))); + return { + field: (component: string, path: readonly (number | string)[] = []) => { + const column = index("field", component, path); + return Array.from({ length: rows }, (_, row) => columns[column]?.[row * (layout.strides[column] ?? 1) + (layout.offsets[column] ?? 0)]); + }, + /** A component's whole interleaved array, padding included. */ + array: (component: string) => [...columns[index("field", component, [0])] ?? []], + changed: (component: string) => [...columns[index("changed", component)] ?? []], + /** A reduced resource's leaf, folded over the rows from its identity. */ + reduced: (resource: string, path: readonly (number | string)[] = []) => + partials[description.reductions.findIndex(reduction => reduction.resource === resource && JSON.stringify(reduction.path) === JSON.stringify(path))], + chunks: (component: string) => [...columns[index("chunks", component)] ?? []], + calls, + }; +}; + +const vec3 = (x: readonly number[], y: readonly number[], z: readonly number[]) => ({ "[0]": x, "[1]": y, "[2]": z }); + +describe("particles", () => { + test("gravity and integrate", async () => { + const gravity = await compile(`const kernel = System.create({ + name: "particles/gravity", kernel: "cpu", + components: { write: [velocity] }, resources: { read: [delta] }, + }, (row, { resources }) => { + const [x, y, z] = row[velocity]; + row[velocity] = [x, y - 9.8 * resources[delta], z]; + });`); + const fallen = run(gravity, 2, { velocity: vec3([1, 2], [0, 10], [3, 4]) }, { resources: [0.5] }); + expect([fallen.field("velocity", [0]), fallen.field("velocity", [1]), fallen.field("velocity", [2])]).toEqual([[1, 2], [-4.9, 5.1], [3, 4]]); + expect(fallen.changed("velocity")).toEqual([tick, tick]); + expect(gravity.shapes).toEqual([{ component: "velocity", leaves: [[0], [1], [2]] }]); + expect(gravity.resources).toEqual(["delta"]); + + const integrate = await compile(`const kernel = System.create({ + name: "particles/integrate", kernel: "cpu", + components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [{ name: "particles/gravity" }], + }, (row, { resources }) => { + const [px, py, pz] = row[position]; + const [vx, vy, vz] = row[velocity]; + const t = resources[delta]; + row[position] = [px + vx * t, py + vy * t, pz + vz * t]; + });`); + const moved = run(integrate, 1, { position: vec3([1], [2], [3]), velocity: vec3([2], [4], [6]) }, { resources: [0.5] }); + expect([moved.field("position", [0]), moved.field("position", [1]), moved.field("position", [2])]).toEqual([[2], [4], [6]]); + expect(moved.changed("position")).toEqual([tick]); + expect(integrate.columns.some(column => column.kind === "changed" && column.component === "velocity")).toBe(false); + // Plain reads: no boolean conversion where it isn't observable. + for (const kernel of [gravity, integrate]) expect(String(kernel.create)).not.toMatch(/\?|booleans\[/); + }); +}); + +describe("layout", () => { + test("interleaved components are read and written at row * stride + offset, leaving padding untouched", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [velocity] } }, (row) => { + const [x, y, z] = row[velocity]; + row[velocity] = [z, row[alive] === true ? y * 10 : y, x]; + });`); + const result = run(kernel, 2, { velocity: vec3([1, 4], [2, 5], [3, 6]), alive: { "[]": [1, 0] } }, { + booleans: ["alive"], + interleaved: { velocity: { stride: 4, offsets: { "[0]": 0, "[1]": 1, "[2]": 2 } }, alive: { stride: 3, offsets: { "[]": 2 } } }, + }); + expect(result.array("velocity")).toEqual([3, 20, 1, padding, 6, 5, 4, padding]); + expect(result.changed("velocity")).toEqual([tick, tick]); + }); + + test("stamps set the changed row and its chunk; each changed column is followed by its chunk column", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + if (row[mass] > 0) row[score] = 1; + });`); + const rows = 2 * 2 ** chunkShift + 5; + const mass = new Array(rows).fill(0); + mass[2 ** chunkShift + 3] = 1; + const result = run(kernel, rows, { mass: { "[]": mass } }); + const changed = kernel.columns.findIndex(column => column.kind === "changed"); + expect(kernel.columns[changed + 1]).toEqual({ kind: "chunks", component: "score" }); + expect(result.chunks("score")).toEqual([0, tick, 0]); + expect(result.changed("score").flatMap((value, row) => value === 0 ? [] : [row])).toEqual([2 ** chunkShift + 3]); + }); + + test("an unconditional stamp stamps the call's chunks once; a stamp after return stays per row", async () => { + const always = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + row[score] = row[mass]; + });`); + expect(String(always.create)).not.toContain(">> 10] = tick"); + expect(run(always, 3200, {}, { range: [1000, 2100] }).chunks("score")).toEqual([tick, tick, tick, 0]); + const after = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + if (row[mass] > 0) return; + row[score] = 1; + });`); + expect(String(after.create)).toContain("[i >> 10] = tick"); + const mass = Array.from({ length: 3200 }, (_, row) => row >= 1024 && row < 2048 ? 1 : 0); + expect(run(after, 3200, { mass: { "[]": mass } }, { range: [1000, 2100] }).chunks("score")).toEqual([tick, 0, tick, 0]); + }); + + test("indices stay in step when return skips the rest of a row", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [velocity] } }, (row) => { + const [x, y, z] = row[velocity]; + if (x > 1 && x < 5) return; + row[velocity] = [z, y, x]; + });`); + const result = run(kernel, 3, { velocity: vec3([1, 4, 7], [2, 5, 8], [3, 6, 9]) }, { interleaved: { velocity: { stride: 4, offsets: { "[0]": 1, "[1]": 2, "[2]": 3 } } } }); + expect(result.array("velocity")).toEqual([padding, 3, 2, 1, padding, 4, 5, 6, padding, 9, 8, 7]); + expect(result.changed("velocity")).toEqual([tick, 0, tick]); + }); +}); + +describe("reductions", () => { + const reducing = (reduce: string, body: string, read = "[position, mass]") => `const total = "total"; +const low = "low"; +const bits = "bits"; +const kernel = System.create({ name: "a", kernel: "cpu", components: { read: ${read} }, reduce: { ${reduce} } }, (row, { reduce }) => { ${body} });`; + const fields = { position: vec3([1, -4, 2], [5, 0, -1], [3, 9, 6]), mass: { "[]": [2, 3, 12] } }; + + test("fold each contribution into its resource, leaf by leaf, from the partial", async () => { + const kernel = await compile(reducing(`[total]: "+", [low]: "min", [bits]: "|"`, ` + const [x, y, z] = row[position]; + reduce[total](row[mass]); + reduce[total](1); + reduce[low]([x, y, z]); + reduce[bits](row[mass]); + `)); + const result = run(kernel, 3, fields); + expect(result.reduced("total")).toBe(2 + 3 + 12 + 3); + expect([0, 1, 2].map(k => result.reduced("low", [k]))).toEqual([-4, -1, 3]); + expect(result.reduced("bits")).toBe(2 | 3 | 12); + expect(kernel.reductions.map(reduction => reduction.operator)).toEqual(["+", "min", "min", "min", "|"]); + }); + + test("fold the values of inlined functions", async () => { + const kernel = await compile(`import { Vec3 } from "ecs";\n${reducing(`[low]: "max"`, "reduce[low](Vec3.scale(row[position], 2));", "[position]")}`); + expect([0, 1, 2].map(k => run(kernel, 3, fields).reduced("low", [k]))).toEqual([4, 10, 18]); + }); + + test("that can't be compiled fail with the reason", async () => { + expect(await failure(reducing(`[total]: "+"`, "reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); + expect(await failure(reducing(`[total]: "avg"`, "reduce[total](1);"))).toBe(`a resource is reduced with one of "+", "*", "min", "max", "&", "|", "^"`); + expect(await failure(reducing(`total: "+"`, "reduce[total](1);"))).toBe(`reduce names each resource as a computed key bound at module scope, as in { [total]: "+" }`); + expect(await failure(reducing(`[total]: "+"`, "reduce[total](1);").replace(`kernel: "cpu"`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); + expect(await failure(reducing(`[total]: "+"`, "reduce[total](1);").replace("reduce: {", "resources: { read: [total] }, reduce: {"))).toBe("total is reduced, so it can't also be read or filtered on"); + expect(await failure(reducing(`[total]: "+", [total]: "max"`, "reduce[total](1);"))).toBe("total is reduced twice"); + }); +}); + +describe("let locals of component values", () => { + test("written back whole after a tuple assignment have the tuple's shape", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [position] } }, (row) => { + let v = row[position]; + if (row[alive]) v = [7, 8, 9]; + row[position] = v; + });`); + expect(kernel.columns.some(column => column.kind === "field" && column.path.length === 0 && column.component === "position")).toBe(false); + const result = run(kernel, 2, { position: vec3([1, 4], [2, 5], [3, 6]), alive: { "[]": [1, 0] } }); + expect([0, 1, 2].map(k => result.field("position", [k]))).toEqual([[7, 4], [8, 5], [9, 6]]); + }); +}); + +describe("binding identity", () => { + test("a local shadowing a component key is not that component", async () => { + expect(await failure(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + const velocity = 1; + row[position] = row[velocity]; + });`)).toBe("row is indexed by a component in components read or write, or .entity"); + }); + + test("another binding with the same key string is not the declared component", async () => { + expect(await failure(`const speed = "velocity"; + const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + row[position] = row[speed]; + });`)).toBe("speed is not in components read or write"); + }); + + test("a helper parameter shadows a module binding only inside the helper", async () => { + const kernel = await compile(`const twice = (velocity) => velocity * 2; + const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + row[position] = twice(row[velocity]) + row[velocity]; + });`); + expect(run(kernel, 1, { velocity: { "[]": [3] } }).field("position")).toEqual([9]); + }); +}); + +describe("value semantics", () => { + test("tuple locals are copies, and snapshots read before later writes", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const before = row[position]; + let v = [before[0], before[1]]; + const w = v; + v = [10, v[1]]; + row[position] = [v[0], 20]; + row[score] = w[0] * 100 + before[1] + row[position][1]; + });`); + const result = run(kernel, 1, { position: { "[0]": [1], "[1]": [2] } }); + expect(result.field("position", [0])).toEqual([10]); + expect(result.field("score")).toEqual([100 + 2 + 20]); + }); + + test("whole-value writes evaluate every leaf before storing", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + row[position] = [row[position][1], row[position][0]]; + let a = 1; + let b = 2; + [a, b] = [b, a]; + row[score] = a * 10 + b; + });`); + const result = run(kernel, 1, { position: { "[0]": [1], "[1]": [2] } }); + expect([result.field("position", [0]), result.field("position", [1])]).toEqual([[2], [1]]); + expect(result.field("score")).toEqual([21]); + }); + + test("struct values and conditional tuples", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const p = row[mass] > 1 ? { x: 1, y: [2, 3] } : { x: 4, y: [5, 6] }; + row[position] = p; + });`); + expect(kernel.shapes).toEqual([{ component: "position", leaves: [["x"], ["y", 0], ["y", 1]] }]); + const result = run(kernel, 2, { mass: { "[]": [2, 0] } }); + expect(result.field("position", ["y", 1])).toEqual([3, 6]); + }); +}); + +describe("helpers", () => { + test("block helpers inline hygienically with tuples flowing through", async () => { + const kernel = await compile(`const scale = (v, s) => { + const [x, y] = v; + const k = s * 2; + return [x * k, y * k]; + }; + const length = ([x, y]) => Math.sqrt(x * x + y * y); + const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const k = 100; + const x = 1; + row[position] = scale(row[position], 0.5); + row[score] = length(row[position]) + k + x; + });`); + const result = run(kernel, 1, { position: { "[0]": [3], "[1]": [4] } }); + expect([result.field("position", [0]), result.field("position", [1])]).toEqual([[3], [4]]); + expect(result.field("score")).toEqual([106]); + }); + + test("recursive helpers are rejected", async () => { + expect(await failure(`const f = (x) => f(x); + const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = f(1); });`)) + .toBe("f is recursive"); + }); +}); + +describe("control flow", () => { + test("return ends the row, also from inside a loop", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + if (row[mass] < 0) return; + let total = 0; + for (let i = 0; i < 10; i++) { + if (i % 2 === 0) continue; + if (i > row[mass]) return; + total += i; + } + row[score] = total; + });`); + expect(run(kernel, 3, { mass: { "[]": [-1, 20, 3] }, score: { "[]": [5, 5, 5] } }).field("score")).toEqual([5, 25, 5]); + }); + + test("a loop test that inlines a helper is re-evaluated each iteration", async () => { + const kernel = await compile(`const below = (a, b) => { const limit = b * 2; return a < limit; }; + const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + let n = 0; + while (below(n, row[mass])) { n++; } + let m = 0; + do { m += 1; } while (m < 3); + row[score] = n * 10 + m; + });`); + expect(run(kernel, 1, { mass: { "[]": [3] } }).field("score")).toEqual([63]); + }); +}); + +describe("context", () => { + test("lookups map components to their read index, with null guards", async () => { + const kernel = await compile(`const half = (value) => { const h = value / 2; return h; }; + const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [target], write: [position, score] }, lookup: { targets: { read: [mass, position] } } }, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + if (other !== null) { row[position] = other[position] - other[mass]; } + row[score] = (other !== null && half(other[position])) || -1; + });`); + expect(kernel.lookups).toEqual(["targets"]); + const found = (entity: number) => entity === 1 ? [3, 10] : null; + const result = run(kernel, 2, { target: { "[]": [1, 2] }, position: { "[]": [0, 0] } }, { lookups: [found], program: false }); + expect(result.field("position")).toEqual([7, 0]); + expect(result.field("score")).toEqual([5, -1]); + }); + + test("optional lookup access, destructured context groups and input", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "number" }, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets }, input }) => { + row[score] = (targets(row[target])?.[mass] ?? 0) + d + input; + });`); + const found = (entity: number) => entity === 1 ? [3] : null; + expect(run(kernel, 2, { target: { "[]": [1, 2] } }, { lookups: [found], resources: [10], args: 100, program: false }).field("score")).toEqual([113, 110]); + }); + + test("context groups reached through the context parameter", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "number" }, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context) => { + const { resources } = context; + row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + context.input; + });`); + const found = (entity: number) => entity === 1 ? [3] : null; + expect(run(kernel, 2, { target: { "[]": [1, 2] } }, { lookups: [found], resources: [10], args: 100, program: false }).field("score")).toEqual([113, 110]); + }); + + test("boolean columns read as booleans", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [score] } }, (row) => { + row[score] = row[alive] === true ? 1 : 2; + });`); + expect(run(kernel, 2, { alive: { "[]": [1, 0] } }, { booleans: ["alive"] }).field("score")).toEqual([1, 2]); + expect(run(kernel, 1, { alive: { "[]": [1] } }).field("score")).toEqual([2]); + }); + + test("boolean reads convert only where observable, including through locals", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive, mass], write: [score] } }, (row, { commands }) => { + const flag = row[alive]; + const copy = flag; + row[score] = (copy === true ? 10 : 20) + row[mass] * 2 + (flag ? 1 : 0); + commands.spawn([alive, mass])({ [alive]: flag, [mass]: row[mass] }); + });`); + const result = run(kernel, 2, { alive: { "[]": [1, 0] }, mass: { "[]": [3, 4] } }, { booleans: ["alive"], program: false }); + expect(result.field("score")).toEqual([17, 28]); + expect(result.calls).toEqual([ + ["spawn", 999], ["spawn", 999, 0, true], ["spawn", 999, 1, 3], + ["spawn", 999], ["spawn", 999, 0, false], ["spawn", 999, 1, 4], + ]); + const source = String(kernel.create); + // Converted: the snapshot of `flag` (observed through `copy ===` and spawn) and `mass` in the spawn value. + expect(source.match(/ \? c\d+\[x\d+\] !== 0/g)?.length).toBe(2); + expect(source.match(/booleans\[/g)?.length).toBe(2); + }); + + test("a spawn nested in an add's value is issued before the add begins", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { + commands.add(row.entity, [target, score], { [target]: commands.spawn([mass])({ [mass]: row[mass] }), [score]: 1 }); + commands.despawn(row.entity); + });`); + expect(kernel.keys).toEqual(["mass", "target", "score"]); + expect(String(kernel.create)).not.toMatch(/\bkeys\[/); + expect(run(kernel, 1, { mass: { "[]": [5] } }, { program: false }).calls).toEqual([ + ["spawn", 999], ["spawn", 999, 0, 5], + [2, 100], [2, 100, 1, 999], [2, 100, 2, 1], + [1, 100], + ]); + }); + + test("commands name components through keys", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [position] } }, (row, context) => { + const { commands } = context; + const spawned = commands.spawn([position, alive])({ [position]: [row[position][0], 1] }); + commands.add(spawned, score, row.entity); + commands.add(spawned, alive); + commands.remove(row.entity, [alive, score]); + commands.remove(row.entity, mass); + });`); + expect(kernel.keys).toEqual(["position", "alive", "score", "mass"]); + // Only position is given a tuple; registration checks such components are stored in linear memory. + expect(kernel.structuredValues).toEqual([0]); + // A component given no value is given undefined, which the runtime writes as the component's start. + expect(run(kernel, 1, { position: { "[0]": [5] } }, { program: false }).calls).toEqual([ + ["spawn", 999], ["spawn", 999, 0, [5, 1]], ["spawn", 999, 1, undefined], + [2, 999], [2, 999, 2, 100], + [2, 999], [2, 999, 1, undefined], + [3, 100], [3, 100, 1], [3, 100, 2], + [3, 100], [3, 100, 3], + ]); + }); + + test("commands name lists through module constants: array literals and Archetype.create", async () => { + const kernel = await compile(`import { Archetype } from "ecs"; + const unit = Archetype.create([position, alive, score]); + const pair = [mass, score]; + const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [position] } }, (row, { commands }) => { + const spawned = commands.spawn(unit)({ [score]: 2, [position]: [row[position][0], 1] }); + commands.add(spawned, pair, { [mass]: 3 }); + commands.remove(row.entity, unit); + });`); + expect(kernel.keys).toEqual(["position", "alive", "score", "mass"]); + expect(kernel.structuredValues).toEqual([0]); + expect(run(kernel, 1, { position: { "[0]": [5] } }, { program: false }).calls).toEqual([ + ["spawn", 999], ["spawn", 999, 0, [5, 1]], ["spawn", 999, 1, undefined], ["spawn", 999, 2, 2], + [2, 999], [2, 999, 3, 3], [2, 999, 2, undefined], + [3, 100], [3, 100, 0], [3, 100, 1], [3, 100, 2], + ]); + }); + + test("kernel declarations list components through module constants", async () => { + const kernel = await compile(`import { Archetype } from "ecs"; + const moving = Archetype.create([position, velocity]); + const kernel = System.create({ name: "a", kernel: "cpu", components: { read: moving, write: [score] } }, row => { + row[score] = row[position] + row[velocity]; + });`); + expect(run(kernel, 2, { position: { "[]": [1, 2] }, velocity: { "[]": [10, 20] } }).field("score")).toEqual([11, 22]); + }); + + test.each([ + ["a value for a component the list doesn't name", "commands.spawn([mass])({ [mass]: 1, [score]: 2 });", "score isn't one of the command's components"], + ["a spawner kept rather than called", "const spawn = commands.spawn([mass]); spawn({ [mass]: 1 });", "a kernel calls the spawner where it gets it, as commands.spawn(components)(values)"], + ["a list holding a spread", "commands.spawn([...pair])({});", "commands.spawn: lists name keys by identifiers bound at module scope"], + ["a list that isn't a module constant", "const list = 1; commands.add(row.entity, list, {});", "commands name components by identifiers bound at module scope"], + ["a component listed twice", "commands.remove(row.entity, [mass, mass]);", "mass is listed twice"], + ["an add with too many arguments", "commands.add(row.entity, mass, 1, 2);", "an add is commands.add(entity, component, value) or commands.add(entity, components, values)"], + ["a remove with rest components", "commands.remove(row.entity, mass, score);", "a remove is commands.remove(entity, component) or commands.remove(entity, components)"], + ])("fail the build for %s", async (_, body, message) => { + expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); + }); +}); + +describe("program", () => { + test("input and resource paths are scalars in the program", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "object" }, components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d }, input }) => { + const [, y] = d; + row[score] = y * input.scale + Math.max(input.scale, Math.PI) + (input.flag ? 1 : 0); + });`); + const result = run(kernel, 1, {}, { resources: [[1, 2]], args: { scale: 3, flag: true } }); + expect(result.field("score")).toEqual([2 * 3 + Math.PI + 1]); + expect(JSON.stringify(kernel.program)).toContain(`{"kind":"resource","index":0,"path":[1]}`); + expect(JSON.stringify(kernel.program)).toContain(`{"kind":"args","path":["scale"]}`); + }); + + test("runtime indexing into a resource has no program", async () => { + const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }) => { + row[score] = d[row[mass]]; + });`); + expect(run(kernel, 1, { mass: { "[]": [1] } }, { resources: [[5, 6]], program: false }).field("score")).toEqual([6]); + }); +}); + +describe("composites", () => { + test("one argument to System.create that isn't a body is a composite, never a kernel", async () => { + const result = await transform(`const a = System.create({ name: "a" }, () => {}); +const listed = System.create([a]); +const declared = System.create({ kernel: "cpu", systems: [a], components: { write: [score] } });`); + expect(result !== null && "failure" in result ? result.failure.message : result?.kernels ?? []).toEqual([]); + }); +}); + +describe("lookups in the program", () => { + const targets = (entity: number) => entity === 1 ? [3, [10, 20, 30]] : entity === 2 ? [4, [40, 50, 60]] : null; + const declaration = (body: string, read = "[target]", lookup = "targets: { read: [mass, position] }") => `const kernel = System.create({ name: "a", kernel: "cpu", + components: { read: ${read}, write: [score] }, lookup: { ${lookup} } }, + (row, { lookup: { ${lookup.includes("others") ? "targets, others" : "targets"} } }) => { ${body} });`; + + test("guarded leaf access, early return and a second lookup in its own handle", async () => { + const kernel = await compile(declaration(` + const other = targets(row[target]); + if (other === null) return; + const [tx, ty] = other[position]; + const second = others(row[mass]); + const extra = second !== null ? second[position][2] : 0; + row[score] = tx - other[mass] + ty * 0 + extra; + `, "[target, mass]", "targets: { read: [mass, position] }, others: { read: [position] }")); + expect(kernel.program?.handles).toBe(2); + const others = (entity: number) => entity === 2 ? [[0, 0, 7]] : null; + expect(run(kernel, 3, { target: { "[]": [1, 2, 3] }, mass: { "[]": [2, 0, 0] }, score: { "[]": [-1, -1, -1] } }, { lookups: [targets, others] }).field("score")) + .toEqual([10 - 3 + 7, 40 - 4, -1]); + }); + + test("optional chains read NaN when nothing was found, anywhere", async () => { + const kernel = await compile(declaration(`row[score] = targets(row[target])?.[position]?.[1] * 2;`, "[target]", "targets: { read: [position] }")); + expect(kernel.program).not.toBeNull(); + expect(run(kernel, 2, { target: { "[]": [1, 5] } }, { lookups: [entity => targets(entity)?.slice(1) ?? null] }).field("score")).toEqual([40, NaN]); + }); + + test("guards through && and conditional tests", async () => { + const kernel = await compile(declaration(` + const other = targets(row[target]); + row[score] = other && other[mass] > 3 ? other[position][0] : -other?.[mass]; + `)); + expect(kernel.program).not.toBeNull(); + expect(run(kernel, 3, { target: { "[]": [1, 2, 3] } }, { lookups: [targets, () => null] }).field("score")).toEqual([-3, 40, NaN]); + }); + + test.each([ + ["an unguarded leaf access", `const other = targets(row[target]); row[score] = other[mass];`, "targets: { read: [mass] }"], + ["a lookup result used whole", `const other = targets(row[target]); row[score] = other;`, "targets: { has: [mass] }"], + ["a leaf access after the guard's scope", `const other = targets(row[target]); if (other !== null) { row[score] = 1; } row[score] = other[mass];`, "targets: { read: [mass] }"], + ])("no program for %s", async (_, body, lookup) => { + const kernel = await compile(declaration(body, "[target]", lookup)); + expect(kernel.program).toBeNull(); + }); +}); + +describe("subset", () => { + const kernel = (body: string, components = "{ read: [mass], write: [score] }") => + `const kernel = System.create({ name: "a", kernel: "cpu", components: ${components} }, (row, { commands }) => { ${body} });`; + + test.each([ + ["row[mass] = 1;", "mass is not in components write"], + ["row[score] = row[score] + row[mass];", "compiled"], + ["const f = () => 1;", "functions can't be created in a kernel; define helpers at module scope"], + ["row[score] = \"a\";", "only number, boolean and null literals are in the kernel subset"], + ["var x = 1;", "use const or let, not var"], + ["return 1;", "a kernel returns nothing; return ends the current row"], + ["row[score] = new Float32Array(1)[0];", "NewExpression is not in the kernel subset"], + ["row[score] = Math.random();", "Math.random is not in the kernel subset"], + ["row[score] = mass;", "mass is a key; use it to index, as in row[mass]"], + ["const v = [1, 2]; row[score] = v[row[mass]];", "tuple elements are indexed by an in-range literal"], + ["let v = [1, 2]; v[0] = 3;", "values are immutable; assign a whole new value to a let local"], + ])("%s", async (body, message) => { + expect(await failure(kernel(body))).toBe(message); + }); + + test("component values are written whole", async () => { + expect(await failure(kernel("row[position][0] = 1;", "{ write: [position] }"))).toBe("component values are immutable; write the whole value"); + }); + + test("GPU kernels", async () => { + const gpu = (declaration: string, body: string) => + `const kernel = System.create({ name: "a", kernel: "gpu", ${declaration} }, (row, context) => { ${body} });`; + expect(await failure(gpu("components: { read: [mass], write: [score] }", "row[score] = row[mass] * 2;"))).toBe("compiled"); + expect(await failure(gpu("components: { read: [target], write: [score] }, lookup: { targets: { read: [mass] } }", "row[score] = 1;"))) + .toBe("a GPU kernel can't use lookups yet"); + expect(await failure(gpu("components: { write: [score], changed: [mass] }", "row[score] = 1;"))).toBe("a GPU kernel can't use change filters yet"); + expect(await failure(gpu("components: { read: [mass], write: [score] }", "context.commands.despawn(row.entity);"))) + .toBe("a GPU kernel can't issue commands or use values that aren't numbers or booleans"); + expect(await failure(gpu("components: { read: [mass], write: [score] }", "let s = 0; for (let i = 0; i < 3; i++) s += i; row[score] = s;"))) + .toBe("a GPU kernel can't use loops or labeled blocks yet"); + expect(await failure(`const kernel = System.create({ name: "a", kernel: true, components: { write: [score] } }, (row) => {});`)) + .toBe(`kernel must be the literal "cpu" or "gpu"`); + }); + + test("GPU programs carry their locals' names", async () => { + const body = "(row) => { const doubled = row[mass] * 2; row[score] = doubled; }"; + const gpu = await compile(`const kernel = System.create({ name: "a", kernel: "gpu", components: { read: [mass], write: [score] } }, ${body});`); + const cpu = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, ${body});`); + expect(gpu.program?.names).toEqual([expect.stringMatching(/^doubled_\d+$/)]); + expect(cpu.program?.names).toBeUndefined(); + }); + + test("kernels inside functions", async () => { + expect(await failure(`const make = () => System.create({ name: "a", kernel: "cpu", components: { write: [score] } }, (row) => {});`)) + .toBe("kernel systems must be declared at module scope"); + }); + + test("failures carry the offending position", async () => { + const source = kernel("row[mass] = 1;"); + const result = await transform(source); + const position = `${header}${source}`.indexOf("row[mass] = 1"); + expect(result !== null && "failure" in result ? result.failure.position : -1).toBe(position); + }); + + test("non-kernel systems that may reach their commands and other modules are left untouched", async () => { + expect(await transform(`const s = System.create({ name: "a", components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); + expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: "cpu" }, () => {});`, "/x.js", parse, async () => false, () => "")).toBeNull(); + }); +}); + +describe("terminal bodies", () => { + /** Whether the transform marks a main-thread system's body as one that never reaches its commands. */ + const marked = async (parameters: string, body: string, declaration = `{ name: "a" }`): Promise => { + const result = await transform(`const system = System.create(${declaration}, ${parameters} => { ${body} });`); + if (result !== null && "failure" in result) throw new Error(result.failure.message); + const code = result?.code ?? ""; + return code.includes("terminal$((") && code.includes("function terminal$(body)"); + }; + test.each([ + ["no context", "(row)", "console.log(row);", `{ name: "a", components: { has: [mass] } }`], + ["a context destructured without commands", "({ resources })", "console.log(resources);", `{ name: "a" }`], + ["a context used only through its other properties", "(context)", "console.log(context.input);", `{ name: "a", input: { type: "number" } }`], + ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ name: "a", components: { has: [mass] } }`], + ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ name: "a", resources: { read: [delta] } }`], + ])("mark a body with %s", async (_, parameters, body, declaration) => { + expect(await marked(parameters, body, declaration)).toBe(true); + }); + test.each([ + ["destructured commands", "({ commands })", "commands.despawn(1);"], + ["renamed commands", "({ commands: issue })", "issue.despawn(1);"], + ["the context's commands", "(context)", "context.commands.despawn(1);"], + ["the context's commands by a computed key", "(context)", `context["commands"].despawn(1);`], + ["the context passed on", "(context)", "console.log(context);"], + ["the context aliased", "(context)", "const c = context; console.log(c);"], + ["a rest of the context", "({ ...rest })", "console.log(rest);"], + ["a computed key of the context", "({ [score]: s })", "console.log(s);"], + ["the context redeclared", "(context)", "{ const context = 1; console.log(context); }"], + ["eval", "(context)", "eval('context.commands');"], + ["a rest parameter", "(...parameters)", "console.log(parameters);"], + ])("leave a body unmarked that reaches its commands through %s", async (_, parameters, body) => { + expect(await marked(parameters, body)).toBe(false); + }); + test("leave a function body unmarked that uses arguments", async () => { + const result = await transform(`const system = System.create({ name: "a" }, function () { console.log(arguments); });`); + expect(result).toBeNull(); + }); + test("mark a system made from its body alone, and no kernel or finalizer", async () => { + const alone = await transform("const system = System.create(() => { console.log(1); });"); + expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(terminal$(() =>"); + const kernel = await transform(`const kernel = System.create({ name: "k", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 1; });`); + expect(kernel !== null && "code" in kernel ? kernel.code : "").not.toContain("terminal$"); + const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { read: [] } }, () => {});`, "/x.js", parse, async () => true, () => ""); + expect(finalizer).toBeNull(); + }); +}); + +describe("index lookups in main-thread systems", () => { + /** Runs the transformed body once, recording each lookup as the runtime hook receives it. */ + const looked = async (body: string, parameters = "({ lookup })", lookup = "near: { index: grid }"): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { + const source = `const grid = "grid";\nconst system = System.create({ name: "a", lookup: { ${lookup} } }, ${parameters} => { ${body} });`; + const result = await transform(source); + if (result !== null && "failure" in result) return { code: result.failure.message, calls: [] }; + const module = (result?.code ?? `${header}${source}`).split("\n").filter(line => !line.startsWith("import ")).join("\n"); + const calls: unknown[] = []; + const prepare = (shape: unknown) => ({ find: (lookup: unknown, ...values: unknown[]) => { calls.push(["site", shape, Reflect.apply(String, undefined, [lookup]), values]); return { length: 0 }; } }); + const system = new Function("System", "prepareLookup$", `${module}\nreturn system;`)({ create: (declaration: unknown, run: unknown) => ({ declaration, run }) }, prepare); + const named = (name: string) => Object.assign((...values: unknown[]) => { calls.push(["call", name, values]); return { length: 0 }; }, { toString: () => name }); + system.run({ lookup: { near: named("near"), due: named("due"), other: named("other") } }); + return { code: result?.code ?? source, calls }; + }; + + test("pass literal tuples and comparisons flattened, through a site of each call", async () => { + const { calls } = await looked(`const x = 1; lookup.near([x, x + 1]); lookup.near([3, 4]); lookup.near(x); lookup.due(x, { ">=": x, "<": [x, 9] });`, "({ lookup })", "near: { index: grid }, due: { index: grid }"); + expect(calls).toEqual([["site", [2], "near", [1, 2]], ["site", [2], "near", [3, 4]], ["site", [0], "near", [1]], ["site", [0, [[">=", 0], ["<", 2]]], "due", [1, 1, 1, 9]]]); + }); + + test.each([ + ["the context's lookup", "context.lookup.near([1, 2]);", "(context)"], + ["a destructured lookup", "near([1, 2]);", "({ lookup: { near } })"], + ])("rewrite %s", async (_, body, parameters) => { + const { calls } = await looked(body, parameters); + expect(calls).toEqual([["site", [2], "near", [1, 2]]]); + }); + + test.each([ + ["a lookup by entity", "lookup.other(1);", "near: { index: grid }, other: { read: [] }"], + ["a spread", "const key = [[1, 2]]; lookup.near(...key);", "near: { index: grid }"], + ["a lookup kept", "const near = lookup.near; near([1, 2]);", "near: { index: grid }"], + ["redeclared lookups", "{ const lookup = {}; } lookup.near([1, 2]);", "near: { index: grid }"], + ["a call inside another's key", "lookup.near(lookup.near(1).length);", "near: { index: grid }"], + ])("leave %s as written", async (_, body, lookup) => { + const { calls } = await looked(body, "({ lookup })", lookup); + expect(calls.some(call => Array.isArray(call) && call[0] === "call")).toBe(true); + }); +}); + +describe("commands in main-thread systems", () => { + /** Runs the transformed system's body once, recording each spawn's and add's keys and values as the runtime hooks receive them. */ + const spawned = async (body: string, parameters = "({ commands })", declaration = `{ name: "a" }`): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { + const source = `const system = System.create(${declaration}, ${parameters} => { ${body} });`; + const result = await transform(source); + if (result !== null && "failure" in result) return { code: result.failure.message, calls: [] }; + const module = (result?.code ?? `${header}${source}`).split("\n").filter(line => !line.startsWith("import ")).join("\n"); + const calls: unknown[] = []; + const prepare = (keys: () => readonly unknown[]) => ({ + begin: (_: unknown, components: unknown) => (...values: unknown[]) => { calls.push(["prepared", components, keys(), values]); return 0; }, + fallback: () => 0, + }); + const prepareAdd = (keys: () => readonly unknown[]) => { + let entity: unknown = undefined; + let list: unknown = undefined; + let values: unknown[] = []; + return { + begin: (_: unknown, target: unknown, components: unknown) => { entity = target; list = components; values = []; return true; }, + value: (k: number, value: unknown) => { values[k] = value; }, + end: () => { calls.push(["prepared add", list, keys(), entity, values]); }, + fallback: () => {}, + }; + }; + const system = new Function("System", "prepareSpawn$", "prepareAdd$", `${module}\nreturn system;`)({ create: (declaration: unknown, run: unknown) => ({ declaration, run }) }, prepare, prepareAdd); + const context = { commands: { spawn: (list: unknown) => (values: unknown) => { calls.push(["spawn", list, values]); }, add: (entity: unknown, list: unknown, values: unknown) => { calls.push(["add", entity, list, values]); } } }; + if (parameters.startsWith("(row")) system.run({}, context); + else system.run(context); + return { code: result?.code ?? source, calls }; + }; + + test("pass literal values positionally, in source order", async () => { + const { calls } = await spawned(`let i = 0; commands.spawn([++i, score, mass])({ [position]: [++i, 0, 0], [score]: ++i, [mass]: 3 }); commands.spawn([])({});`); + expect(calls).toEqual([["prepared", [1, "score", "mass"], ["position", "score", "mass"], [[2, 0, 0], 3, 3]], ["prepared", [], [], []]]); + }); + + test.each([ + ["a string literal key", `commands.spawn([score, mass])({ [score]: 1, "mass": 3 });`], + ["a plain key", "commands.spawn([score, mass])({ [score]: 1, mass: 3 });"], + ["a number key", "commands.spawn([score])({ 1: 3 });"], + ])("fail the build for %s, since component keys are symbols", async (_, body) => { + const { code, calls } = await spawned(body); + expect(code).toBe("a command's keys are its components' symbols, as [key]; a plain or string key is never a component's"); + expect(calls).toEqual([]); + }); + + test.each([ + ["the context's commands", "context.commands.spawn([score])({ [score]: 1 });", "(context)", `{ name: "a" }`], + ["renamed commands", "issue.spawn([score])({ [score]: 1 });", "({ commands: issue })", `{ name: "a" }`], + ["a system with components", "commands.spawn([score])({ [score]: 1 });", "(row, { commands })", `{ name: "a", components: { has: [mass] } }`], + ["the context of a system with components", "context.commands.spawn([score])({ [score]: 1 });", "(row, context)", `{ name: "a", components: { has: [mass] } }`], + ])("pass values positionally through %s", async (_, body, parameters, declaration) => { + const { calls } = await spawned(body, parameters, declaration); + expect(calls).toEqual([["prepared", ["score"], ["score"], [1]]]); + }); + + test("pass values positionally in a system made from its body alone", async () => { + const result = await transform("const system = System.create(({ commands }) => { commands.spawn([score])({ [score]: 1 }); });"); + expect(result !== null && "code" in result ? result.code : null).toContain("spawn$0(commands, [score], 1)"); + }); + + test.each([ + ["a value object built first", "const values = { [score]: 1 }; commands.spawn([score])(values);"], + ["a spread", "const values = { [score]: 1 }; commands.spawn([score, mass])({ ...values, [mass]: 2 });"], + ["a key that isn't a module constant", "const key = score; commands.spawn([score])({ [key]: 1 });"], + ["a shadowed key", "const score = \"other\"; commands.spawn([score])({ [score]: 1 });"], + ["redeclared commands", "{ const commands = { spawn: () => {} }; } commands.spawn([score])({ [score]: 1 });"], + ["a spawner kept", "const spawn = commands.spawn([score]); spawn({ [score]: 1 });"], + ])("leave %s as written", async (_, body) => { + const { calls } = await spawned(body); + expect(calls.every(call => Array.isArray(call) && call[0] === "spawn")).toBe(true); + expect(calls.length).toBe(1); + }); + describe("adds", () => { + test("pass literal values positionally, after the entity, in source order", async () => { + const { code, calls } = await spawned("let i = 0; commands.add(++i, [score, mass, ++i], { [score]: ++i, [mass]: ++i });"); + expect(code).toContain("prepareAdd$"); + expect(calls).toEqual([["prepared add", ["score", "mass", 2], ["score", "mass"], 1, [3, 4]]]); + }); + test.each([ + ["a value object built first", "const values = { [score]: 1 }; commands.add(1, [score], values);"], + ["a spread", "const values = { [score]: 1 }; commands.add(1, [score, mass], { ...values, [mass]: 2 });"], + ["a key that isn't a module constant", "const key = score; commands.add(1, [score], { [key]: 1 });"], + ["one component", "commands.add(1, score, 1);"], + ["one component's object value", `commands.add(1, score, { text: "a", "size": 2 });`], + ])("leave %s as written", async (_, body) => { + const { calls } = await spawned(body); + expect(calls).toEqual([["add", 1, expect.anything(), expect.anything()]]); + }); + test("fail the build for a plain or string key", async () => { + expect((await spawned(`commands.add(1, [score, mass], { [score]: 1, "mass": 3 });`)).code).toBe("a command's keys are its components' symbols, as [key]; a plain or string key is never a component's"); + }); + }); +}); + +describe("helpers that return early", () => { + test("take each path's value, with later statements on the paths that fall through", async () => { + const kernel = await compile(`const pick = (a, b) => { + if (a > b) return [a, 0]; + const c = b * 2; + if (c > 10) { + const d = c - 10; + return [d, 1]; + } + return [c, 2]; + }; + const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const [value, path] = pick(row[mass], 4); + row[position] = [value, path, 0]; + });`); + const result = run(kernel, 3, { mass: { "[]": [5, 3, 1] }, position: vec3([0, 0, 0], [0, 0, 0], [0, 0, 0]) }); + expect([result.field("position", [0]), result.field("position", [1])]).toEqual([[5, 8, 8], [0, 2, 2]]); + }); +}); + +describe("math builtins", () => { + const math = `import { Vec3, Quat, Mat4x4 } from "ecs";\n`; + const points = vec3([1, -2, 0.5], [0, 3, 4], [2, 1, -1]); + const at = (row: number): readonly [number, number, number] => [points["[0]"][row], points["[1]"][row], points["[2]"][row]]; + const results = (result: ReturnType) => [0, 1, 2].map(row => [0, 1, 2].map(k => result.field("position", [k])[row])); + + test("inline as scalar code, nested namespaces and helpers they call included", async () => { + const kernel = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position, score] } }, (row) => { + const p = row[position]; + const v = row[velocity]; + row[position] = Vec3.add(Vec3.normalize(Vec3.cross(p, v)), Vec3.F32.scale(Vec3.clamp(v, Vec3.zero, [1, 1, 1]), Vec3.dot(p, v))); + row[score] = Vec3.distance(p, v) + Vec3.length(Vec3.mix(p, v, 0.25)); + });`); + const result = run(kernel, 3, { position: points, velocity: vec3([0.5, 1, 2], [1, 0, -1], [3, -2, 0]) }); + const velocities: readonly (readonly [number, number, number])[] = [[0.5, 1, 3], [1, 0, -2], [2, -1, 0]]; + const expected = [0, 1, 2].map(row => { + const p = at(row); + const v = velocities[row]; + return Vec3.add(Vec3.normalize(Vec3.cross(p, v)), Vec3.scale(Vec3.clamp(v, Vec3.zero, [1, 1, 1]), Vec3.dot(p, v))); + }); + expect(results(result)).toEqual(expected.map(value => [...value])); + expect(result.field("score")).toEqual([0, 1, 2].map(row => Vec3.distance(at(row), velocities[row]) + Vec3.length(Vec3.mix(at(row), velocities[row], 0.25)))); + expect(String(kernel.create)).not.toMatch(/Vec3|normalize|cross/); + }); + + test("across namespaces: quaternions rotating vectors, and matrices", async () => { + const kernel = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + const q = Quat.fromAxisAngle([0, 0, 1], Math.PI / 2); + const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); + row[position] = Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])); + });`); + const result = run(kernel, 3, { position: points }); + const q = Quat.fromAxisAngle([0, 0, 1], Math.PI / 2); + const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); + expect(results(result)).toEqual([0, 1, 2].map(row => [...Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, at(row)))])); + }); + + test("in GPU kernels, become regions WGSL emits as builtins, and other backends run as the inlined body", async () => { + const body = `(row) => { row[position] = Vec3.scale(Vec3.normalize(Vec3.cross(row[position], row[velocity])), Vec3.dot(row[position], row[velocity])); }`; + const declaration = `components: { read: [velocity], write: [position] }`; + const gpu = await compile(`${math}const kernel = System.create({ name: "a", kernel: "gpu", ${declaration} }, ${body});`); + const cpu = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", ${declaration} }, ${body});`); + const regions = (gpu.program?.body ?? []).flatMap(statement => statement.kind === "intrinsic" ? [statement.intrinsic.operation] : []); + expect(regions).toEqual(["cross", "normalize", "dot", "*"]); + expect((cpu.program?.body ?? []).some(statement => statement.kind === "intrinsic")).toBe(false); + const fields = { position: points, velocity: vec3([0.5, 1, 2], [1, 0, -1], [3, -2, 0]) }; + expect(results(run(gpu, 3, fields))).toEqual(results(run(cpu, 3, fields))); + const columns = gpu.columns.map((column): WasmColumn => ({ view: column.kind === "field" ? "f32" : "f64", stride: 4, offset: 0, boolean: false, component: 0 })); + const shader = gpu.program === null ? "" : generateWgsl(gpu.program, columns, () => "number"); + const code = typeof shader === "string" ? shader : shader.code; + expect(code).toMatch(/(normalize_\d+) = normalize\((cross_\d+)\);/); + expect(code).toMatch(/= \(normalize_\d+ \* dot_\d+\);/); + }); + + test("that leave the kernel subset fail with the function's reason", async () => { + expect(await failure(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + row[position] = Vec3.random(); + });`)).toBe("Math.random is not in the kernel subset"); + expect(await failure(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + row[position] = Vec3.missing([1, 2, 3]); + });`)).toMatch(/^missing isn't exported by .*\/math\/vec3\/public\.ts$/); + }); +}); + +describe("imported functions and constants", () => { + const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { ${body} });`; + const scores = async (source: string, files: { readonly [file: string]: string }) => + run(await compile(source, files), 2, { mass: { "[]": [2, 3] } }).field("score"); + + test("inline through named, default, namespace and re-exported imports", async () => { + const files = { + "/shapes.js": "const TWO = 2;\nexport const twice = (x) => x * TWO;\nexport default function square(x) { return x * x; }\nexport const pair = (x) => [x, x + 1];", + "/second.js": "const ONE = 1;\nexport const second = (v) => v[ONE];", + "/barrel.js": "export { twice as double } from './shapes.js';\nexport * as Shapes from './shapes.js';\nexport * from './second.js';", + }; + expect(await scores(kernel(`import square, { twice } from "./shapes.js";\nimport * as Barrel from "./barrel.js";`, + "row[score] = square(twice(row[mass])) + Barrel.double(1) + Barrel.Shapes.default(3) + Barrel.second(Barrel.Shapes.pair(row[mass]));"), files)) + .toEqual([16 + 2 + 9 + 3, 36 + 2 + 9 + 4]); + }); + + test("report the files kernels inline from, also when they fail, so fixing them compiles again", async () => { + const result = await transform(kernel(`import { twice } from "./shapes.js";`, "row[score] = twice(row[mass]);"), { "/shapes.js": "export const twice = (x) => x * 2;" }); + expect(result?.dependencies).toEqual(["/shapes.js"]); + const failed = await transform(kernel(`import { twice } from "./shapes.js";`, "row[score] = twice(row[mass]);"), { "/shapes.js": "export const twice = (x) => x * Math.random();" }); + expect(failed !== null && "failure" in failed).toBe(true); + expect(failed?.dependencies).toEqual(["/shapes.js"]); + }); + + test("that leave the subset fail at the call, with a frame for each inlined function", async () => { + const source = kernel(`import { outer } from "./outer.js";`, "row[score] = outer(row[mass]);"); + const result = await transform(source, { + "/outer.js": "import { inner } from './inner.js';\nexport const outer = (x) => inner(x) + 1;", + "/inner.ts": "type Unused = {\n readonly a: number;\n};\nexport const inner = (x: number): number =>\n x * Math.random();", + }); + const failure = result !== null && "failure" in result ? result.failure : undefined; + expect(failure?.message).toBe("Math.random is not in the kernel subset"); + expect(failure?.position).toBe(`${header}${source}`.indexOf("outer(row[mass])")); + expect(failure?.frames).toEqual([ + { name: "inner", file: "/inner.ts", line: 5, column: 9 }, + { name: "outer", file: "/outer.js", line: 2, column: 29 }, + ]); + }); + + test("frame each function of a module that fails inside another of its functions", async () => { + const result = await transform(kernel(`import { outer } from "./outer.js";`, "row[score] = outer(row[mass]);"), { + "/outer.js": "const inner = (x) => x * Math.random();\nexport const outer = (x) => inner(x) + 1;", + }); + const failure = result !== null && "failure" in result ? result.failure : undefined; + expect(failure?.frames).toEqual([ + { name: "inner", file: "/outer.js", line: 1, column: 26 }, + { name: "outer", file: "/outer.js", line: 2, column: 29 }, + ]); + }); + + test("fail on recursion across modules, module state, and names they can't resolve", async () => { + expect(await failure(kernel(`import { even } from "./even.js";`, "row[score] = even(row[mass]);"), { + "/even.js": "import { odd } from './odd.js';\nexport const even = (n) => n === 0 ? 1 : odd(n - 1);", + "/odd.js": "import { even } from './even.js';\nexport const odd = (n) => n === 0 ? 0 : even(n - 1);", + })).toBe("even is recursive"); + expect(await failure(kernel(`import { next } from "./counter.js";`, "row[score] = next();"), { "/counter.js": "let count = 0;\nexport const next = () => count;" })) + .toBe("count is not a module-scope const"); + expect(await failure(kernel(`import { missing } from "./shapes.js";`, "row[score] = missing(1);"), { "/shapes.js": "export const twice = (x) => x * 2;" })) + .toBe("missing isn't exported by /shapes.js"); + expect(await failure(kernel(`import { f } from "./both.js";`, "row[score] = f(1);"), { + "/both.js": "export * from './a.js';\nexport * from './b.js';", "/a.js": "export const f = (x) => x;", "/b.js": "export const f = (x) => -x;", + })).toBe("f is exported by more than one export * of /both.js"); + expect(await failure(kernel(`import { f } from "lodash";`, "row[score] = f(1);"))).toBe("lodash can't be resolved from /test.js"); + }); +}); + +describe("imported lists", () => { + const files = { + "/components.js": `export const health = "health";\nexport const armor = "armor";\nexport const tagged = "tagged";\n`, + "/archetypes.js": [ + `import { Archetype } from "ecs";`, + `import { health, armor, tagged } from "./components.js";`, + `export const shield = "shield";`, + `export const Unit = Archetype.create([health, armor, tagged]);`, + `export const Guard = [shield, armor];`, + `export const Alias = Unit;`, + `export const Computed = [health].concat([armor]);`, + `export let Changing = [health];`, + `export const Spread = [...Guard];`, + ].join("\n"), + "/barrel.js": `export { Unit as Soldier } from "./archetypes.js";\nexport * from "./archetypes.js";\nexport * as Kinds from "./archetypes.js";\n`, + }; + /** The values the archetypes' module gives its lists, for evaluating the kernel's module. */ + const Unit = ["health", "armor", "tagged"]; + const Guard = ["shield", "armor"]; + const commanding = (imports: string, body: string) => + `${imports}\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { ${body} });`; + + test("name the keys of a list imported from another module, by the kernel's bindings or else through the list", async () => { + const source = commanding(`import { health, armor } from "./components.js";\nimport { Unit, Guard } from "./archetypes.js";`, ` + const spawned = commands.spawn(Unit)({ [health]: row[mass], [armor]: 2 }); + commands.add(spawned, Guard, { [armor]: 3 }); + commands.remove(row.entity, Unit);`); + const result = await transform(source, files); + expect(result !== null && "code" in result ? result.code : "").toContain("keys: [health, armor, Unit[2], Guard[0]],"); + expect(result?.dependencies).toEqual(expect.arrayContaining(["/archetypes.js", "/components.js"])); + const kernel = await compile(source, files, { health: "health", armor: "armor", Unit, Guard }); + expect(kernel.keys).toEqual(["health", "armor", "tagged", "shield"]); + expect(run(kernel, 1, { mass: { "[]": [5] } }, { program: false }).calls).toEqual([ + ["spawn", 999], ["spawn", 999, 0, 5], ["spawn", 999, 1, 2], ["spawn", 999, 2, undefined], + [2, 999], [2, 999, 3, undefined], [2, 999, 1, 3], + [3, 100], [3, 100, 0], [3, 100, 1], [3, 100, 2], + ]); + }); + + test("follow re-exports, namespace imports and constants naming another list", async () => { + const source = commanding(`import { health } from "./components.js";\nimport { Soldier, Alias } from "./barrel.js";\nimport * as Archetypes from "./archetypes.js";\nimport * as Barrel from "./barrel.js";\nconst Local = Alias;`, ` + commands.spawn(Soldier)({ [health]: row[mass] }); + commands.spawn(Archetypes.Unit)({ [health]: 2 }); + commands.add(row.entity, Barrel.Kinds.Guard, {}); + commands.remove(row.entity, Local);`); + const result = await transform(source, files); + expect(result !== null && "code" in result ? result.code : result?.failure.message).toContain("keys: [health, Soldier[1], Soldier[2], Barrel.Kinds.Guard[0]],"); + const kernel = await compile(source, files, { health: "health", Soldier: Unit, Alias: Unit, Archetypes: { Unit, Guard }, Barrel: { Kinds: { Guard } } }); + expect(kernel.keys).toEqual(["health", "armor", "tagged", "shield"]); + expect(run(kernel, 1, { mass: { "[]": [5] } }, { program: false }).calls).toEqual([ + ["spawn", 999], ["spawn", 999, 0, 5], ["spawn", 999, 1, undefined], ["spawn", 999, 2, undefined], + ["spawn", 999], ["spawn", 999, 0, 2], ["spawn", 999, 1, undefined], ["spawn", 999, 2, undefined], + [2, 100], [2, 100, 3, undefined], [2, 100, 1, undefined], + [3, 100], [3, 100, 0], [3, 100, 1], [3, 100, 2], + ]); + }); + + test("in a declaration, bind keys the list's module defines to the kernel's imports of them, under any name", async () => { + const source = `import { shield, Guard } from "./archetypes.js";\nimport { armor as plate } from "./components.js"; +const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; + const kernel = await compile(source, files, { shield: "shield", Guard, plate: "armor" }); + expect(run(kernel, 2, { shield: { "[]": [1, 2] }, armor: { "[]": [3, 4] } }).field("score")).toEqual([13, 24]); + expect(await failure(`import { shield, Guard } from "./archetypes.js"; +const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield]; });`, files)) + .toBe("Guard[1] is in components read but never read; remove it, or move it to components has to keep requiring it"); + }); + + test.each([ + ["computed when its module runs", "commands.spawn(Computed)({});", "commands.spawn: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"], + ["not a const", "commands.spawn(Changing)({});", "commands.spawn: Changing isn't a module-scope const, so the compiler can't read its components"], + ["holding a spread", "commands.spawn(Spread)({});", "commands.spawn: Spread names a key by something other than an identifier bound at the scope of /archetypes.js"], + ["not exported", "commands.spawn(Missing)({});", "commands.spawn: Missing isn't exported by /archetypes.js"], + ["a namespace", "commands.spawn(Archetypes)({});", "commands.spawn: Archetypes is a module namespace, not a list of components"], + ["a member of a namespace it doesn't export", "commands.remove(row.entity, Archetypes.Missing);", "commands.remove: Missing isn't exported by /archetypes.js"], + ])("fail the build for a list %s", async (_, body, message) => { + expect(await failure(commanding(`import { Computed, Changing, Spread, Missing } from "./archetypes.js";\nimport * as Archetypes from "./archetypes.js";`, body), files)).toBe(message); + }); + + test("take an imported name as one key when it resolves to a key, followed through namespaces and constants", async () => { + const keys = { + ...files, + "/keys.js": [ + `export const ember = Symbol("ember");`, + `export const glow = identify$(Symbol("glow"), "keys.js#glow");`, + `export const spark = ember;`, + `function identify$(symbol, id) { return symbol; }`, + ].join("\n"), + }; + const source = commanding(`import { ember, glow, spark } from "./keys.js";\nimport * as Keys from "./keys.js";\nimport { heat } from "external";`, ` + commands.add(row.entity, ember, row[mass]); + commands.add(row.entity, glow, true); + commands.remove(row.entity, Keys.ember); + commands.remove(row.entity, spark); + commands.add(row.entity, heat, 2);`); + const result = await transform(source, keys); + expect(result !== null && "code" in result ? result.code : result?.failure.message).toContain("keys: [ember, glow, spark, heat],"); + }); + + test.each([ + ["computed when its module runs", "commands.add(row.entity, Computed, {});", "commands.add: Computed is computed when /archetypes.js runs, so the compiler can't tell whether it's a key or a list; declare a key as Symbol(...), or a list as an array literal or Archetype.create of one"], + ["not a const", "commands.remove(row.entity, Changing);", "commands.remove: Changing isn't a module-scope const, so the compiler can't tell whether it's a key or a list"], + ["not exported", "commands.remove(row.entity, Missing);", "commands.remove: Missing isn't exported by /archetypes.js"], + ["a namespace", "commands.add(row.entity, Archetypes, 1);", "commands.add: Archetypes is a module namespace, not a key or a list of components"], + ["of a module that can't be resolved", "commands.add(row.entity, Lost, 1);", "commands.add: nowhere can't be resolved from /test.js"], + ])("fail the build for an added or removed name %s", async (_, body, message) => { + expect(await failure(commanding(`import { Computed, Changing, Missing } from "./archetypes.js";\nimport * as Archetypes from "./archetypes.js";\nimport { Lost } from "nowhere";`, body), files)).toBe(message); + }); + + test("fail the build for a list that names a key", async () => { + expect(await failure(commanding(`import { health } from "./components.js";`, "commands.spawn(health)({});"), files)) + .toBe("commands.spawn: health is a key, not a list of components; list it as [health]"); + }); + + test("fail the build for a declaration's list computed when its module runs", async () => { + expect(await failure(`import { Computed } from "./archetypes.js"; +const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Computed, write: [score] } }, (row) => { row[score] = 1; });`, files)) + .toBe("components.read: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"); + }); +}); + +describe("declared access", () => { + const kernel = (declaration: string, body: string) => + `const kernel = System.create({ name: "a", kernel: "cpu", ${declaration} }, (row, { resources, lookup, reduce }) => { ${body} });`; + const system = (declaration: string, body: string) => `const system = System.create({ name: "a", ${declaration} }, ${body});`; + + test("a kernel uses every component and resource it lists", async () => { + expect(await failure(kernel("components: { read: [position, velocity], write: [score] }", "row[score] = row[velocity];"))) + .toBe("position is in components read but never read; remove it, or move it to components has to keep requiring it"); + expect(await failure(kernel("components: { read: [position], write: [velocity, mass] }", "row[velocity] = row[position];"))) + .toBe("mass is in components write but never written; remove it, or move it to components read if it's only read"); + expect(await failure(kernel("components: { write: [score] }, resources: { read: [delta] }", "row[score] = 1;"))) + .toBe("delta is in resources read but never read; remove it"); + expect(await failure(kernel("components: { read: [target], write: [score] }, lookup: { targets: { read: [mass, position] } }", + "const found = lookup.targets(row[target]); row[score] = found === null ? 0 : found[mass];"))) + .toBe("position is in lookup.targets read but never read; remove it, or move it to lookup.targets has to keep requiring it"); + expect(await failure(kernel("components: { read: [mass], has: [alive] }", "const entity = row.entity;"))) + .toBe("mass is in components read but never read; remove it, or move it to components has to keep requiring it"); + expect(await failure(kernel("components: { read: [position, mass], write: [score] }, resources: { read: [delta] }", + "row[score] = row[mass] * resources[delta] + row[position][0];"))).toBe("compiled"); + }); + + test("a kernel reads every component it lists in up.read, and up holds only those", async () => { + const up = (kind: string, read: string, body: string) => + `const kernel = System.create({ name: "a", kernel: "${kind}", components: { read: [mass], write: [score] }, up: { through: target, read: [${read}] } }, (row, { up }) => { ${body} });`; + const propagate = "row[score] = (up === null ? 0 : up[score]) + row[mass];"; + expect(await failure(up("cpu", "score, position", propagate))).toBe("position is in up.read but never read; remove it"); + expect(await failure(up("cpu", "score", "row[score] = up === null ? row[mass] : up[position];"))).toBe("up is indexed by a component in up.read"); + expect(await failure(up("gpu", "score", propagate))).toBe("compiled"); + expect(await failure(up("cpu", "score", propagate))).toBe("compiled"); + }); + + test("a main-thread system uses every component and resource it lists, where its body shows every access", async () => { + const unread = "mass is in components read but never read; remove it, or move it to components has to keep requiring it"; + expect(await failure(system("components: { read: [position, mass], write: [velocity] }", "(row) => { row[velocity] = row[position]; }"))).toBe(unread); + expect(await failure(system("components: { read: [position], write: [velocity] }", "(row) => { console.log(row[position]); }"))) + .toBe("velocity is in components write but never written; remove it, or move it to components read if it's only read"); + expect(await failure(system("components: { read: [mass] }", "() => {}"))).toBe(unread); + expect(await failure(system("components: { read: [mass] }", "(row, { commands }) => commands.despawn(row.entity)"))).toBe(unread); + expect(await failure(system("components: { write: [score] }", "(row) => { row[score] += 1; }"))).toBe("compiled"); + }); + + test("a main-thread system uses every resource it lists, through its context's resources however it reaches them", async () => { + const unread = "delta is in resources read but never read; remove it"; + // Without components, the context is the first parameter. + expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { context.resources[score] = 1; }"))).toBe(unread); + expect(await failure(system("resources: { read: [delta], write: [score] }", "({ resources }) => { resources[score] = 1; }"))).toBe(unread); + expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { const { resources } = context; resources[score] = 1; }"))).toBe(unread); + expect(await failure(system("resources: { read: [delta] }, input: { type: \"number\" }", "({ input }) => { console.log(input); }"))).toBe(unread); + expect(await failure(system("resources: { write: [score, mass] }", "({ resources }) => { resources[score] = 1; }"))).toBe("mass is in resources write but never written; remove it, or move it to resources read if it's only read"); + expect(await failure(system("components: { has: [mass] }, resources: { read: [delta], write: [score] }", + "(_, context) => { context.resources[score] = context.resources[delta]; }"))).toBe("compiled"); + expect(await failure(system("resources: { read: [delta] }", "({ resources: { [delta]: d } }) => { console.log(d); }"))).toBe("compiled"); + }); + + test("a system contributes to every resource it reduces", async () => { + const totals = `const total = "total";\nconst low = "low";\n`; + const missing = "low is in reduce but never contributed to; remove it"; + expect(await failure(`${totals}${kernel(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "reduce[total](row[mass]);")}`)).toBe(missing); + expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "(row, { reduce }) => { reduce[total](row[mass]); }")}`)).toBe(missing); + expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "(row, context) => { context.reduce[total](row[mass]); }")}`)).toBe(missing); + expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+" }`, "(row, { reduce }) => { const add = reduce[total]; add(row[mass]); }")}`)).toBe("compiled"); + }); + + test("a main-thread system reads every component it lists in a lookup, where its body shows every use of the result", async () => { + const looking = (lookup: string, body: string) => system(`components: { read: [target] }, lookup: { targets: { read: ${lookup} } }`, body); + const unread = "position is in lookup.targets read but never read; remove it, or move it to lookup.targets has to keep requiring it"; + expect(await failure(looking("[mass, position]", "(row, context) => { const found = context.lookup.targets(row[target]); if (found !== null) console.log(found[mass]); }"))).toBe(unread); + expect(await failure(looking("[mass, position]", "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[mass]); }"))).toBe(unread); + expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { console.log(targets(row[target])?.[mass]); }"))).toBe(unread); + expect(await failure(looking("[mass]", "(row, { lookup: { targets } }) => { const found = targets(row[target]); if (!found) return; console.log(found[mass]); }"))) + .toBe("compiled"); + expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { const found = targets(row[target]); console.log(found); }"))).toBe("compiled"); + expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { let found = targets(row[target]); console.log(found?.[mass]); }"))).toBe("compiled"); + expect(await failure(looking("[mass, position]", "(row, { lookup }) => { helper(lookup); console.log(row[target]); }"))).toBe("compiled"); + }); + + test("a main-thread system reads every component it lists in a lookup through an index, from its lists' rows", async () => { + const looking = (lookup: string, body: string) => system(`components: { read: [target] }, lookup: { near: { index: grid, read: ${lookup} } }`, body); + const unread = "position is in lookup.near read but never read; remove it"; + expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); for (let i = 0; i < near.length; i++) console.log(near.at(i)[mass], near.changed); }"))).toBe(unread); + expect(await failure(looking("[mass, position]", "(row, context) => { const other = context.lookup.near(row[target]).at(0); console.log(other[mass], other.entity); }"))).toBe(unread); + expect(await failure(looking("[mass, position]", "(row, { lookup: { near } }) => { console.log(near(row[target])?.[mass]); }"))).toBe(unread); + expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); console.log(near.at(0)[mass], near.at(1)[position]); }"))).toBe("compiled"); + expect(await failure(looking("[mass, position]", "(row, { lookup }) => { helper(lookup.near(row[target])); }"))).toBe("compiled"); + expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); const all = near.at; console.log(all); }"))).toBe("compiled"); + }); + + test("a main-thread system reads every component it lists through a destructured row", async () => { + const unread = "mass is in components read but never read; remove it, or move it to components has to keep requiring it"; + expect(await failure(system("components: { read: [position, mass] }", "({ [position]: p, entity }) => { console.log(p, entity); }"))).toBe(unread); + expect(await failure(system("components: { read: [position, mass] }", "(row) => { const { [position]: p } = row; console.log(p); }"))).toBe(unread); + expect(await failure(system("components: { read: [position] }", "({ ...all }) => { console.log(all); }"))).toBe("compiled"); + }); + + test("finalizers are checked as main-thread bodies, in files that import only Finalizer", async () => { + const finalizing = (source: string) => transformKernels(source, "/finalizer.js", parse, async specifier => specifier === "ecs", index => JSON.stringify(`kernel:${index}`)); + const failed = async (source: string) => { + const result = await finalizing(source); + return result !== null && "failure" in result ? result.failure.message : "compiled"; + }; + const header = `import { Finalizer } from "ecs";\nconst handle = "handle";\nconst count = "count";\n`; + expect(await failed(`${header}const f = Finalizer.create({ name: "f", components: { read: [handle, count] } }, (row) => { console.log(row[handle]); });`)) + .toBe("count is in components read but never read; remove it, or move it to components has to keep requiring it"); + expect(await failed(`${header}const f = Finalizer.changes({ name: "f", resources: { read: [count] } }, (changes, { resources }) => { console.log(changes); });`)) + .toBe("count is in resources read but never read; remove it"); + expect(await failed(`${header}const f = Finalizer.changes({ name: "f", resources: { read: [count] } }, (changes, { resources }) => { console.log(changes, resources[count]); });`)) + .toBe("compiled"); + expect(await failed(`${header}const f = Finalizer.create({ name: "f", components: { read: [handle] } }, (row) => { console.log(row[handle]); });`)).toBe("compiled"); + }); + + test("a list declared as a module constant is checked as its array literal", async () => { + const moving = `const moving = [position, velocity];\n`; + expect(await failure(`${moving}${system("components: { read: moving }", "(row) => { console.log(row[position]); }")}`)) + .toBe("velocity is in components read but never read; remove it, or move it to components has to keep requiring it"); + expect(await failure(`${moving}${system("components: { read: moving }", "(row) => { console.log(row[position], row[velocity]); }")}`)).toBe("compiled"); + expect(await failure(`${moving}${kernel("components: { read: moving, write: [score] }", "row[score] = row[position];")}`)) + .toBe("velocity is in components read but never read; remove it, or move it to components has to keep requiring it"); + }); + + test("a main-thread system whose body hides an access isn't checked", async () => { + expect(await failure(system("components: { read: [mass] }", "(row) => { helper(row); }"))).toBe("compiled"); + expect(await failure(system("components: { read: [mass] }", "(row) => { for (const key of [mass]) console.log(row[key]); }"))).toBe("compiled"); + expect(await failure(system("components: { read: [mass] }", "(row) => { const mass = \"other\"; console.log(row[mass]); }"))).toBe("compiled"); + expect(await failure(system("components: { read: [mass] }", "function (row) { console.log(arguments[0][mass]); }"))).toBe("compiled"); + expect(await failure(system("resources: { read: [delta] }", "({ ...all }) => { console.log(all); }"))).toBe("compiled"); + expect(await failure(system("resources: { read: [delta] }", "({ resources: { ...all } }) => { console.log(all); }"))).toBe("compiled"); + expect(await failure(system("resources: { read: [delta] }", "(context) => { helper(context.resources); }"))).toBe("compiled"); + expect(await failure(system("resources: { read: [delta] }", "(context) => { let r = context.resources; console.log(r); }"))).toBe("compiled"); + expect(await failure(system("components: { read: [mass] }", "body"))).toBe("compiled"); + expect(await failure("const decay = (k) => System.create({ name: \"a\", components: { read: [k] } }, (row) => { console.log(row[k]); });")).toBe("compiled"); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts new file mode 100644 index 000000000..3fdc5c74c --- /dev/null +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -0,0 +1,239 @@ +import type { Node, ObjectExpression, Program, SimpleCallExpression } from 'estree'; +import { compileKernel, type CompiledKernel } from './compile-kernel.js'; +import { children, isFunction } from './estree.js'; +import { fail, failureOf, type Failure } from './failure.js'; +import { isPending, type Link, type Linker } from './link.js'; +import { createModule, locateIn, type Module } from './module-scope.js'; +import { attachKernelPath, prepareLookupPath, prepareSpawnPath } from './paths.js'; +import { spawnSites } from './spawns.js'; +import { kernelOf } from './signature.js'; +import { usedBy } from './body-access.js'; +import { requireExact } from './exact.js'; +import { createLists } from './lists.js'; +import { neverReachesCommands } from './terminal.js'; +import { lookupSites } from './lookups.js'; +import { terminalBodiesName } from '../world/terminal.js'; + +export type Parse = (code: string) => Program; +/** Whether an import specifier, resolved from the module being transformed, is the package's, which exports `System` and `Finalizer`. */ +export type ResolveSystemImport = (source: string) => Promise; +/** + * The JS expression for kernel `index`'s module url. `identity` is everything the description and module are + * compiled to (the description without its url, plus the module source), so it can version the url. + */ +export type KernelUrl = (index: number, identity: string) => string; +/** + * `kernels[i]` is the source of kernel `i`'s module, which exports `create`. `dependencies` are the other files + * whose functions and constants the kernels inline, or that compiling them read before failing. + */ +export type TransformResult = + | { readonly code: string; readonly kernels: readonly string[]; readonly dependencies: readonly string[] } + | { readonly failure: Failure; readonly dependencies: readonly string[] }; + +type Edit = { readonly start: number; readonly end: number; readonly text: string }; + +/** The local names of the package's `System` and `Finalizer` namespaces, by the namespace each names. */ +const systemLocals = async (program: Program, resolveSystemImport: ResolveSystemImport): Promise> => { + const locals = new Map(); + for (const statement of program.body) { + if (statement.type !== "ImportDeclaration" || typeof statement.source.value !== "string") continue; + const specifiers = statement.specifiers.flatMap(specifier => specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" + && (specifier.imported.name === "System" || specifier.imported.name === "Finalizer") ? [{ local: specifier.local.name, imported: specifier.imported.name }] : []); + if (specifiers.length > 0 && await resolveSystemImport(statement.source.value)) { + for (const { local, imported } of specifiers) locals.set(local, imported === "System" ? "System" : "Finalizer"); + } + } + return locals; +}; + +type Created = "system" | "finalizer" | "changes"; + +/** `System.create`, or `Finalizer.create` or `Finalizer.changes`, whose bodies run on the main thread; null for any other call. */ +const createdBy = (node: SimpleCallExpression, locals: ReadonlyMap): Created | null => { + if (node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.object.type !== "Identifier" || node.callee.property.type !== "Identifier") return null; + const namespace = locals.get(node.callee.object.name); + const method = node.callee.property.name; + if (namespace === "System") return method === "create" ? "system" : null; + return namespace === "Finalizer" && (method === "create" || method === "changes") ? (method === "create" ? "finalizer" : "changes") : null; +}; + +/** Which parameters are the row and the context: a system without `components` takes only its context, and a changes finalizer takes its changes first. */ +const positionsOf = (created: Created, declaration: ObjectExpression): { readonly row: number; readonly context: number } => { + if (created === "changes") return { row: -1, context: 1 }; + const rowed = declaration.properties.some(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "components"); + return rowed ? { row: 0, context: 1 } : { row: -1, context: 0 }; +}; + +/** + * A spawn site's function: the components, then one parameter per value, so values pass without an object to a + * spawner generated for the site's key order and the list's shape (`PreparedSpawn` in `world/prepared-spawn.ts`). + * The keys are read in module scope, as the prepared spawn's argument, so no binding the function declares can capture + * one (`hygiene.test.ts`). + */ +const spawnFunction = (name: string, prepare: string, keys: readonly string[]): string => { + const values = keys.map((_, k) => `v${k}`).join(", "); + return [ + `const ${name} = (p => (c, l${keys.length > 0 ? `, ${values}` : ""}) => {`, + "const s = p.begin(c, l);", + `return s === null ? p.fallback(c, l, [${values}]) : s(${values});`, + `})(${prepare}(() => [${keys.join(", ")}]));`, + ].join("\n"); +}; + +/** + * An add site's function: the entity, the components, then one parameter per value, each appended straight to the + * commands' buffer (`PreparedAdd` in `world/prepared-spawn.ts`). Its keys are read in module scope, as a spawn site's are. + */ +const addFunction = (name: string, prepare: string, keys: readonly string[]): string => { + const values = keys.map((_, k) => `v${k}`); + return [ + `const ${name} = (p => (c, e, l${values.map(value => `, ${value}`).join("")}) => {`, + `if (p.begin(c, e, l)) { ${values.map((value, k) => `p.value(${k}, ${value}); `).join("")}p.end(); }`, + `else p.fallback(c, e, l, [${values.join(", ")}]);`, + `})(${prepare}(() => [${keys.join(", ")}]));`, + ].join("\n"); +}; + +const uniqueName = (code: string, base: string): string => { + let name = base; + for (let n = 1; code.includes(name); n++) name = `${base}${n}`; + return name; +}; + +/** Compiles a kernel, loading what it imports as compiling reaches it. */ +const compileLinked = async (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu", link: Link | null): Promise => { + for (;;) { + try { + return compileKernel(declaration, body, module, target, link); + } catch (error) { + if (!isPending(error)) throw error; + await error.pending; + } + } +}; + +type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly target: "cpu" | "gpu" }; + +/** + * Wraps each kernel `System.create(declaration, body)` call as `attachKernel(call, description)`, and returns each + * kernel's `create` as its own module, referenced by `description.url`. Functions and constants kernels import are + * inlined through `linker`. + * Also passes the values of main-thread spawns and adds of literal keys positionally (`spawns.ts`). + * Returns null when the module has neither, and a `Failure` when a kernel leaves the subset. + */ +export const transformKernels = async ( + code: string, id: string, parse: Parse, resolveSystemImport: ResolveSystemImport, kernelUrl: KernelUrl, linker?: Linker, +): Promise => { + if ((!code.includes("System") && !code.includes("Finalizer")) || id.includes("\0")) return null; + const program = parse(code); + const locals = await systemLocals(program, resolveSystemImport); + if (locals.size === 0) return null; + const file = id.split("?")[0] ?? id; + const module = createModule(file, program, locateIn(code)); + linker?.add(module); + const dependencies = new Set(); + /** Main-thread declarations are checked only against lists of their own module. */ + const mainThreadLists = createLists(module, null, []); + const link: Link | null = linker === undefined ? null : { + module: dependency => { + if (dependency !== file) dependencies.add(dependency); + return linker.module(dependency); + }, + resolve: linker.resolve, + }; + const attach = uniqueName(code, "attachKernel$"); + const prepare = uniqueName(code, "prepareSpawn$"); + const prepareAddName = uniqueName(code, "prepareAdd$"); + const spawnName = uniqueName(code, "spawn$"); + const terminal = uniqueName(code, "terminal$"); + const prepareLookupName = uniqueName(code, "prepareLookup$"); + const lookupName = uniqueName(code, "lookup$"); + let looking = false; + let terminals = false; + const edits: Edit[] = []; + const kernels: string[] = []; + const sites: KernelSite[] = []; + /** Module-scope declarations of prepared spawns, appended after everything they name is declared. */ + const prepared: string[] = []; + let adding = false; + let spawning = false; + const visit = (node: Node, nested: boolean): void => { + const created = node.type === "CallExpression" ? createdBy(node, locals) : null; + const [first, second] = node.type === "CallExpression" ? node.arguments : []; + // One argument that isn't a body makes a composite, of a list or a declaration: never a kernel, nor checked. + const composite = created === "system" && first !== undefined && second === undefined && !isFunction(first); + if (node.type === "CallExpression" && created !== null && !composite) { + // A system that declares nothing is made from its body alone, which takes only its context. + const alone = created === "system" && first !== undefined && isFunction(first) && second === undefined; + const declaration = alone ? undefined : first; + const body = alone ? first : second; + const target = declaration?.type === "ObjectExpression" ? kernelOf(declaration) : null; + if (declaration?.type === "ObjectExpression" && target !== null) { + if (nested) fail(node, "kernel systems must be declared at module scope"); + if (body === undefined || body.type === "SpreadElement") return fail(node, "a kernel needs an inline body"); + sites.push({ node, declaration, body, target }); + return; + } + const positions = alone ? { row: -1, context: 0 } : declaration?.type === "ObjectExpression" ? positionsOf(created, declaration) : null; + if (declaration?.type === "ObjectExpression" && positions !== null && body !== undefined && body.type !== "SpreadElement") { + requireExact(declaration, usedBy(declaration, body, module.bindings, positions), module.bindings, mainThreadLists); + } + // A system whose body never reaches its commands is terminal where its declaration allows (`world/tails.ts`). + if (created === "system" && positions !== null && body !== undefined && body.type !== "SpreadElement" && neverReachesCommands(body, positions.context)) { + terminals = true; + edits.push({ start: body.start, end: body.start, text: `${terminal}(` }, { start: body.end, end: body.end, text: ")" }); + } + const spawns = positions === null || body === undefined || body.type === "SpreadElement" ? null : spawnSites(body, module.bindings, positions.context); + // Index lookups pass their key positionally, through a list of their own (`world/prepared-lookup.ts`). + const lookups = positions === null || body === undefined || body.type === "SpreadElement" || declaration?.type !== "ObjectExpression" ? [] : lookupSites(body, declaration, positions.context); + const spawned = spawns?.sites ?? []; + for (const site of lookups) { + if (spawned.some(other => other.start < site.end && site.start < other.end)) continue; + const name = `${lookupName}${prepared.length}`; + prepared.push(`const ${name} = ${prepareLookupName}(${JSON.stringify(site.shape)}).find;`); + looking = true; + const values = [site.callee, ...site.values].map(value => code.slice(value.start, value.end)); + edits.push({ start: site.start, end: site.end, text: `${name}(${values.join(", ")})` }); + } + if (spawns !== null && spawns.sites.length > 0) { + for (const site of spawns.sites) { + const name = `${spawnName}${prepared.length}`; + prepared.push(site.kind === "spawn" ? spawnFunction(name, prepare, site.keys) : addFunction(name, prepareAddName, site.keys)); + if (site.kind === "add") adding = true; + else spawning = true; + const values = [...site.entity === null ? [] : [site.entity], site.components, ...site.values].map(value => code.slice(value.start, value.end)); + edits.push({ start: site.start, end: site.end, text: `${name}(${[spawns.commands, ...values].join(", ")})` }); + } + return; + } + } + const inner = nested || isFunction(node); + for (const child of children(node)) visit(child, inner); + }; + try { + visit(program, false); + for (const { node, declaration, body, target } of sites) { + const compiled = await compileLinked(declaration, body, module, target, link); + const source = `export const create = ${compiled.create};\n`; + const description = compiled.description(kernelUrl(kernels.length, `${compiled.description("")}\n${source}`)); + kernels.push(source); + edits.push({ start: node.start, end: node.end, text: `${attach}(${code.slice(node.start, node.end)}, ${description})` }); + } + } catch (error) { + const failure = failureOf(error); + if (failure === undefined) throw error; + return { failure, dependencies: [...dependencies] }; + } + if (edits.length === 0) return null; + let result = code; + for (const edit of [...edits].sort((a, b) => b.start - a.start)) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + const imports = [ + ...kernels.length > 0 ? [`import { attachKernel as ${attach} } from ${JSON.stringify(attachKernelPath)};`] : [], + ...spawning ? [`import { prepareSpawn as ${prepare} } from ${JSON.stringify(prepareSpawnPath)};`] : [], + ...adding ? [`import { prepareAdd as ${prepareAddName} } from ${JSON.stringify(prepareSpawnPath)};`] : [], + ...looking ? [`import { prepareLookup as ${prepareLookupName} } from ${JSON.stringify(prepareLookupPath)};`] : [], + ]; + const marking = terminals ? [`function ${terminal}(body) { const set = Symbol.for(${JSON.stringify(terminalBodiesName)}); (globalThis[set] || (globalThis[set] = new WeakSet())).add(body); return body; }`] : []; + return { code: [...imports, result, ...prepared, ...marking].join("\n"), kernels, dependencies: [...dependencies] }; +}; diff --git a/packages/ecs/src/implementation/compiler/unit.ts b/packages/ecs/src/implementation/compiler/unit.ts new file mode 100644 index 000000000..c14315a7e --- /dev/null +++ b/packages/ecs/src/implementation/compiler/unit.ts @@ -0,0 +1,114 @@ +import type { Node } from 'estree'; +import type { Expression } from './ir.js'; +import type { Link } from './link.js'; +import type { Lists } from './lists.js'; +import type { Module } from './module-scope.js'; +import type { Out } from './out.js'; +import type { Scope } from './scope.js'; +import type { Signature } from './signature.js'; +import type { Value } from './value.js'; + +export type ExpressionCompiler = (node: Node, scope: Scope, out: Out) => Value; +export type StatementCompiler = (node: Node, scope: Scope, out: Out) => void; + +/** + * State for compiling one kernel. Compilers are injected so expression, call and statement modules + * don't import each other. + */ +export type Unit = { + readonly signature: Signature; + /** The module being transformed. */ + readonly module: Module; + /** The kernel's lists of keys, wherever they are declared. */ + readonly lists: Lists; + /** Other modules, for functions and constants the kernel imports. */ + readonly link: Link | null; + /** GPU kernels emit intrinsic regions for calls to functions that match WGSL builtins. */ + readonly target: "cpu" | "gpu"; + /** Column descriptions as source text, in `columns` order, indexed by an identity key. */ + readonly columns: string[]; + readonly columnIndex: Map; + /** Chunk columns: dense, indexed by `i >> chunkShift` rather than a strided row index. */ + readonly chunks: Set; + /** Chunk columns stamped once per call before the row loop, so their per-row stamps are dropped. */ + readonly stampedPerCall: Set; + /** Columns read where a boolean's identity is observable, so they need conversion. */ + readonly booleans: Set; + /** Every value assigned to each local, to propagate observation. */ + readonly assignments: Map; + /** Locals whose value reaches an observable position. */ + readonly observed: Set; + /** Source names of locals, for readable JS. */ + readonly names: Map; + /** Shape descriptions as source text. */ + readonly shapes: Set; + /** The shapes of values contributed to reductions, as source text. */ + readonly reducedShapes: Set; + readonly keys: string[]; + /** The components a command or spawn gives a tuple or struct, by name. */ + readonly structuredValues: Set; + /** Each list the kernel spawns, as indexes into `keys`, listed once. */ + readonly spawns: (readonly number[])[]; + /** Each leaf a reduction folds into: its resource (an index into `Signature.reduced`), path and accumulator local. */ + readonly accumulators: { readonly reduced: number; readonly path: readonly (number | string)[]; readonly local: number }[]; + /** The components, resources and lookup results the body reads and writes, to check the declaration lists only those. */ + readonly used: { readonly read: Set; readonly write: Set; readonly resources: Set; readonly lookups: Map>; readonly up: Set }; + /** The local holding `up`, set once per call: non-null when the rows of the call have a parent. */ + readonly up: number | null; + /** Columns of the parent's components that `up` reads, as source text, indexed by an identity key. */ + readonly upColumns: string[]; + readonly upColumnIndex: Map; + /** Parent columns read where a boolean's identity is observable. */ + readonly upBooleans: Set; + /** Each call site of a lookup through an index, as `KernelDescription.probes` lists it, as source text. */ + readonly probes: string[]; + /** The row leaves bodies read, as `KernelDescription.rowLeaves` lists them, by `JSON.stringify` of each. */ + readonly rowLeaves: string[]; + /** Locals holding lists and rows of lookups through indexes, which are handles each kernel instance makes once. */ + readonly handles: Set; + /** Helpers and constants being inlined, to reject recursion. */ + readonly active: Set; + readonly next: () => number; + readonly expression: ExpressionCompiler; + readonly statement: StatementCompiler; +}; + +export const createUnit = (signature: Signature, module: Module, lists: Lists, expression: ExpressionCompiler, statement: StatementCompiler, link: Link | null = null, target: "cpu" | "gpu" = "cpu"): Unit => { + let counter = 0; + const names = new Map(); + const up = signature.up === null ? null : counter++; + if (up !== null) names.set(up, "up"); + return { + signature, module, lists, link, target, columns: [], columnIndex: new Map(), chunks: new Set(), stampedPerCall: new Set(), booleans: new Set(), assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), + used: { read: new Set(), write: new Set(), resources: new Set(), lookups: new Map(), up: new Set() }, + up, upColumns: [], upColumnIndex: new Map(), upBooleans: new Set(), probes: [], rowLeaves: [], handles: new Set(), + next: () => counter++, expression, statement, + }; +}; + +/** A fresh local, printed as `hint_n` so it never collides with fixed generated names. */ +export const fresh = (unit: Unit, hint: string): number => { + const local = unit.next(); + unit.names.set(local, hint); + return local; +}; + +/** A fresh block or loop label. */ +export const label = (unit: Unit): number => unit.next(); + +/** The `keys` index for a component named in a command. */ +export const keyIndex = (unit: Unit, name: string): number => { + const index = unit.keys.indexOf(name); + if (index >= 0) return index; + unit.keys.push(name); + return unit.keys.length - 1; +}; + +/** The `spawns` index for a list of components a kernel spawns. */ +export const spawnIndex = (unit: Unit, names: readonly string[]): number => { + const keys = names.map(name => keyIndex(unit, name)); + const index = unit.spawns.findIndex(list => list.length === keys.length && list.every((key, k) => key === keys[k])); + if (index >= 0) return index; + unit.spawns.push(keys); + return unit.spawns.length - 1; +}; diff --git a/packages/ecs/src/implementation/compiler/value.ts b/packages/ecs/src/implementation/compiler/value.ts new file mode 100644 index 000000000..c5d2c1255 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/value.ts @@ -0,0 +1,60 @@ +import type { BlockStatement, Expression as SourceExpression, Pattern } from 'estree'; +import type { Path, Program } from '../world/kernel.js'; +import type { Expression } from './ir.js'; +import type { Module } from './module-scope.js'; +import type { Snapshot } from './snapshot.js'; + +/** + * A compile-time value. `scalar` and `dynamic` hold an expression; `dynamic` is a plain JS value + * (resource, `args`, lookup element) that may be indexed at runtime. Tuples and structs are scalarized. + * `component` is an unread `row[component]` reference; `snapshot` is a local copy of one, read lazily per leaf. + */ +export type Value = + | { readonly kind: "scalar"; readonly expression: Expression } + | { readonly kind: "dynamic"; readonly expression: Expression } + | { readonly kind: "tuple"; readonly items: readonly Value[] } + | { readonly kind: "struct"; readonly fields: readonly (readonly [string, Value])[] } + /** With `up`, a component of the row's parent, read through `up`. */ + | { readonly kind: "component"; readonly component: string; readonly path: Path; readonly up?: true } + | { readonly kind: "snapshot"; readonly snapshot: Snapshot; readonly path: Path } + | { readonly kind: "found"; readonly lookup: number; readonly expression: Expression } + | { readonly kind: "lookup"; readonly lookup: number } + /** + * What a call of a lookup through an index gave: its list, in local `list`, and its first row, in local `first`, which + * a unique index's result is read as. + */ + | { readonly kind: "indexed"; readonly lookup: number; readonly list: number; readonly first: number } + /** A list's `at`, which takes a row's position. */ + | { readonly kind: "listAt"; readonly lookup: number; readonly list: number } + /** A row a lookup through an index gave, in local `row`, and a component of it, `read` an index into the lookup's read, at `path`. */ + | { readonly kind: "indexRow"; readonly lookup: number; readonly row: number } + | { readonly kind: "rowRead"; readonly lookup: number; readonly row: number; readonly read: number; readonly path: Path } + /** A resource the kernel reduces into, by its index in `Signature.reduced`. */ + | { readonly kind: "reducer"; readonly reduced: number } + /** + * A helper, compiled in its own module. `identity` is unique per definition; `name` is for messages. `intrinsic` is + * the WGSL builtin it matches, if its module declares one. + */ + | { readonly kind: "function"; readonly name: string; readonly identity: string; readonly params: readonly Pattern[]; readonly body: BlockStatement | SourceExpression; readonly module: Module; readonly intrinsic?: Program.Intrinsic } + /** A module namespace, such as `Vec3`, at its file. */ + | { readonly kind: "namespace"; readonly file: string } + | { readonly kind: "method"; readonly owner: "math" | "commands"; readonly name: string } + | { readonly kind: "row" } + | { readonly kind: "context" } + /** The context's `resources`, `lookup` and `reduce`. */ + | { readonly kind: "resources" } + | { readonly kind: "lookups" } + | { readonly kind: "reducers" } + | { readonly kind: "commands" } + | { readonly kind: "math" }; + +export const scalarValue = (expression: Expression): Value => ({ kind: "scalar", expression }); + +const describe: { readonly [K in Value["kind"]]: string } = { + scalar: "a number", dynamic: "a value", tuple: "a tuple", struct: "a struct", component: "a component value", + snapshot: "a component value", found: "a lookup result", lookup: "a lookup", indexed: "a lookup's list", listAt: "a list's at", indexRow: "a lookup's row", rowRead: "a value of a lookup's row", reducer: "a reduction", function: "a helper", + method: "a method", row: "row", context: "the context", resources: "the resources", lookups: "the lookups", reducers: "the reductions", commands: "commands", math: "Math", namespace: "a namespace", +}; + +/** A short description of a value's kind, for error messages. */ +export const describeValue = (value: Value): string => describe[value.kind]; diff --git a/packages/ecs/src/implementation/index.ts b/packages/ecs/src/implementation/index.ts new file mode 100644 index 000000000..588d8a0c2 --- /dev/null +++ b/packages/ecs/src/implementation/index.ts @@ -0,0 +1,2 @@ +export { create, createWorld, join, type CreatedWorld, type WorldOptions } from './world/create.js'; +export { backToBack } from './world/loop.js'; diff --git a/packages/ecs/src/implementation/world/activate.ts b/packages/ecs/src/implementation/world/activate.ts new file mode 100644 index 000000000..e04753d4c --- /dev/null +++ b/packages/ecs/src/implementation/world/activate.ts @@ -0,0 +1,127 @@ +import type { Finalizer } from '../../interface/finalizer/created.js'; +import type { Rule } from '../../interface/rule/created.js'; +import type { RuleFacts } from '../../interface/registry/strata.js'; +import { createFinalizerRunner, placeFinalizer, type FinalizerRunner } from './finalizers.js'; +import { finalizerKeysOf, type TaskChannel } from './remote-systems.js'; +import { programFinalizers, programRules, programVersion } from '../../interface/registry/program.js'; +import { triggersOf } from './program.js'; +import { ensureResources, findResource } from './resources.js'; +import { idOf, metIdOf, type Key, type State } from './state.js'; +import { restratify } from './structure.js'; +import { idOfItem, idOfKey, labelOf, labelOfItem, threadOf } from '../../interface/registry/index.js'; + +/** + * Why a joined thread that imported the items of ids `imported` can't claim `threads`: a program finalizer on one that + * it never imported, or that has no id or names a key without one, so it can't name it; null when none. + */ +export const finalizerCrossing = (threads: readonly string[], imported: ReadonlySet): string | null => { + for (const finalizer of programFinalizers()) { + if (finalizer.kind !== "finalizer") continue; + const thread = threadOf(finalizer.declaration, "Finalizer", finalizer.name); + if (thread === null || !threads.includes(thread)) continue; + const label = labelOfItem(finalizer); + const id = idOfItem(finalizer); + if (id === undefined) return `Finalizer "${label}" runs on thread "${thread}" and has no id, so a joined thread can't run it; the build gives top-level finalizers one.`; + if (!imported.has(id)) return `Finalizer "${label}" runs on thread "${thread}", which the joined thread claiming it never imported.`; + const unnamed = finalizerKeysOf(finalizer).find(key => idOfKey(key) === undefined); + if (unnamed !== undefined) return `Finalizer "${label}" runs on thread "${thread}" and names "${labelOf(unnamed)}", which has no id, so a joined thread can't name it; the build gives top-level keys one.`; + } + return null; +}; + +/** The channel of the joined thread a finalizer runs on, or null for the world's thread. */ +const channelOf = (state: State, finalizer: Finalizer): TaskChannel | null => { + if (!state.claimed || finalizer.kind !== "finalizer") return null; + const thread = threadOf(finalizer.declaration, "Finalizer", finalizer.name); + const channel = thread === null ? undefined : state.threadTasks.get(thread); + return channel === undefined || channel.gone() ? null : channel; +}; + +/** + * Places a runner on its joined thread, or here: false when that thread can't name it, which the next sync point + * reports once. + */ +const placed = (state: State, runner: FinalizerRunner): boolean => { + try { + placeFinalizer(state, runner, channelOf(state, runner.finalizer)); + return true; + } + catch (error) { + if (!state.unplacedSeen.has(runner.finalizer)) { + state.unplacedSeen.add(runner.finalizer); + state.unplaced.push({ finalizer: runner.finalizer, error }); + } + return false; + } +}; + +/** + * Between runs, once claims were made or released: each active finalizer runs on the joined thread filling its thread, + * or here. One its thread can't name stops being active, and activates again once its thread is released. + */ +export const placeFinalizers = (state: State): void => { + const kept = state.finalizers.filter(runner => (channelOf(state, runner.finalizer) ?? null) === (runner.remote?.channel ?? null) || placed(state, runner)); + if (kept.length === state.finalizers.length) return; + for (const runner of state.finalizers) if (!kept.includes(runner)) state.activated.delete(runner.finalizer); + state.finalizers.splice(0, state.finalizers.length, ...kept); +}; + +const resourcesOf = (finalizer: Finalizer): readonly Key[] => [...finalizer.declaration.resources?.read ?? [], ...finalizer.declaration.resources?.write ?? []]; + +/** Between runs: adds the resources of every finalizer in the program, so one can activate at any sync point. */ +export const ensureProgramResources = (state: State, ensured: { version: number }): void => { + const version = programVersion(); + if (ensured.version === version) return; + ensured.version = version; + ensureResources(state, new Set(programFinalizers().flatMap(resourcesOf))); + // Finalizers waiting for these can activate at the next sync point. + state.activation.met = true; +}; + +const ready = (state: State, item: Rule | Finalizer): boolean => { + const { keys, any } = triggersOf(item); + return keys.length === 0 || (any ? keys.some(key => metIdOf(state, key) !== undefined) : keys.every(key => metIdOf(state, key) !== undefined)); +}; + +/** + * At the start of a sync point: activates the program's rules and finalizers that can match here now, so they apply + * from this sync point on. Until the world has met each component one needs, nothing can match it. Activating + * resolves its components' ids, which may make more ready. + */ +export const activate = (state: State): void => { + const { activation, activated, structure } = state; + if (!activation.met && activation.version === programVersion()) return; + const facts: RuleFacts[] = []; + const runners = new Map(); + while (activation.met || activation.version !== programVersion()) { + activation.met = false; + activation.version = programVersion(); + for (const rule of programRules()) { + if (activated.has(rule) || !ready(state, rule)) continue; + activated.add(rule); + const { components, derive } = rule.declaration; + const ids = (keys: readonly Key[]): number[] => keys.map(key => idOf(state, key)); + facts.push({ has: ids(components.has ?? []), not: ids(components.not ?? []), derives: ids(derive) }); + } + for (const finalizer of programFinalizers()) { + // A finalizer added during a run waits for its resources, which are added between runs. + if (activated.has(finalizer) || !ready(state, finalizer) || resourcesOf(finalizer).some(key => findResource(state, key) === undefined)) continue; + // One a joined thread runs is placed there; one it can't name doesn't activate, which the sync point reports once. + if (state.unplacedSeen.has(finalizer)) continue; + const runner = createFinalizerRunner(state, finalizer); + if (!placed(state, runner)) continue; + activated.add(finalizer); + runners.set(finalizer, runner); + } + } + if (facts.length > 0) { + restratify(structure, [...structure.rules, ...facts], structure.hosts, id => labelOf(state.keys[id])); + // Entities may already match a rule added to the program after the world met its components. + structure.rederive = true; + } + if (runners.size > 0) { + const existing = new Map(state.finalizers.map(runner => [runner.finalizer, runner])); + const ordered = programFinalizers().flatMap(finalizer => runners.get(finalizer) ?? existing.get(finalizer) ?? []); + state.finalizers.splice(0, state.finalizers.length, ...ordered); + } +}; diff --git a/packages/ecs/src/implementation/world/archetype.ts b/packages/ecs/src/implementation/world/archetype.ts new file mode 100644 index 000000000..0452a3876 --- /dev/null +++ b/packages/ecs/src/implementation/world/archetype.ts @@ -0,0 +1,387 @@ +import type { Entity } from '../../interface/entity/entity.js'; +import type { Layout, Views } from '../../interface/registry/layout.js'; +import { allocateArray, allocateViews, emptyViews, freeArray, freeViews, growArray } from './arrays.js'; +import { chunkShift } from './kernel.js'; +import type { Memory } from './memory.js'; +import { nullTag, startTag, type MoveLog } from './move-log.js'; +import type { StringStore } from './strings.js'; + +/** One component's storage in one archetype. Its views and ticks are replaced when the archetype grows. */ +export type Storage = { + readonly component: number; + readonly layout: Layout; + /** + * Views of the interleaved column, for a linear layout: row `r` is `layout.stride` bytes at `r * stride`. For a + * string, each row's handle of its record while records are kept, else 0, as in rows past the archetype's count. + */ + views: Views; + /** For a string, the records its handles name; null otherwise. */ + readonly strings: StringStore | null; + /** Values, for an object layout the world's thread fills, and a string's on the world's thread. */ + readonly values: unknown[]; + /** For an object layout a joined thread fills, its log, and each row's tag (`move-log.ts`) in place of its value. */ + remote: MoveLog | null; + tags: Uint32Array; + /** How the sync point writes a value without a closure call: one of the `write` kinds, with its leaves' placement. */ + readonly write: number; + readonly unitStride: number; + readonly unitOffset: number; + readonly lanes: number; + /** 32-bit words per element of a linear layout whose stride is a multiple of 4, which every GPU layout is; else 0. */ + readonly words: number; + /** + * Whether this component keeps ticks: only once some system filters on its `added` or `changed`, and always for + * resources. Without them the tick arrays are empty and nothing stamps. Ticks are f64 so they never wrap. + */ + ticked: boolean; + added: Float64Array; + changed: Float64Array; + /** The newest `added` and `changed` tick in each chunk of `2 ** chunkShift` rows, so change filters skip unchanged chunks. */ + chunkAdded: Float64Array; + chunkChanged: Float64Array; + /** + * The newest tick at which every row may have changed, as a GPU kernel's writes do, so stamping them costs + * nothing per row: change filters pass the whole column when it's newer than their `lastRun`. + */ + columnChanged: number; +}; + +/** Bytes after a column's last row, so a WASM kernel's 16-byte load of a row's leaves never reads past the column. */ +export const columnSlack = 16; + +/** Write kinds: a scalar of one view, or a tuple of `lanes` consecutive f32s, like a vec3; anything else uses `layout.write`. */ +export const writeF32 = 1; +export const writeU32 = 2; +export const writeI32 = 3; +export const writeF64 = 4; +export const writeF32Lanes = 5; +export const writeGeneral = 0; + +export const writeOf = (layout: Layout): { readonly write: number; readonly unitStride: number; readonly unitOffset: number; readonly lanes: number } => { + const general = { write: writeGeneral, unitStride: 0, unitOffset: 0, lanes: 0 }; + if (layout.kind !== "linear") return general; + const [first] = layout.leaves; + if (first === undefined || layout.leaves.some(leaf => leaf.scalar === "boolean")) return general; + if (layout.leaves.length === 1 && first.path.length === 0) { + const kinds: { readonly [view: string]: number } = { f32: writeF32, u32: writeU32, i32: writeI32, f64: writeF64 }; + return { write: kinds[first.view] ?? writeGeneral, unitStride: first.unitStride, unitOffset: first.unitOffset, lanes: 1 }; + } + const lanes = layout.leaves.every((leaf, i) => leaf.view === "f32" && leaf.path.length === 1 && leaf.path[0] === i && leaf.unitStride === first.unitStride && leaf.unitOffset === first.unitOffset + i); + return lanes ? { write: writeF32Lanes, unitStride: first.unitStride, unitOffset: first.unitOffset, lanes: layout.leaves.length } : general; +}; + +/** Chunks covering `capacity` rows. */ +const chunksFor = (capacity: number): number => Math.max(1, Math.ceil(capacity / 2 ** chunkShift)); + +/** What stamping a row's ticks writes: a storage's, or a joined thread's view of one. */ +export type Stamped = Pick; + +/** Stamps a row's `changed` tick and its chunk's. Ticks only increase, so the chunk's is the newest. */ +export const stampChanged = (storage: Stamped, row: number, tick: number): void => { + if (!storage.ticked) return; + storage.changed[row] = tick; + storage.chunkChanged[row >> chunkShift] = tick; +}; + +export const stampAdded = (storage: Stamped, row: number, tick: number): void => { + if (!storage.ticked) return; + storage.added[row] = tick; + storage.chunkAdded[row >> chunkShift] = tick; +}; + +/** A table of the entities that have exactly `components`. Rows are dense: `0` to `count`. */ +export type Archetype = { + readonly id: number; + /** Sorted component ids. */ + readonly components: readonly number[]; + readonly storages: readonly Storage[]; + readonly storageOf: ReadonlyMap; + /** Storages by component id, for hot paths. */ + readonly storageById: readonly (Storage | undefined)[]; + /** The archetype with one more or one fewer component, by component id, cached as edges are first taken. */ + readonly adding: (Archetype | undefined)[]; + readonly removing: (Archetype | undefined)[]; + /** For a move to another archetype: each component they share, as source and target storages. */ + /** By target archetype id: the storages they share, as source and target pairs, flattened. */ + readonly moves: (readonly Storage[] | undefined)[]; + entities: Float64Array; + count: number; + capacity: number; + /** Holds the resource entity, so queries and systems never see it. */ + readonly resource: boolean; + /** Holds reports, which only queries that require a component see. */ + readonly meta: boolean; + /** Whether a storage holds values as objects or strings, which a removed row must release. */ + readonly objects: boolean; + /** Its storages of strings, whose records a removed row lets go. */ + readonly strings: readonly Storage[]; + /** The logs of the joined threads whose objects it holds, which its moves are appended to; null for none. */ + logs: MoveLog[] | null; + /** Whether it's listed for the layout directory's next flush of counts. */ + counted: boolean; + /** Incremented whenever arrays are replaced, so cached views can be refreshed. */ + version: number; + /** How many times any of the world's archetypes' arrays were replaced, shared by them all. */ + readonly replaced: { count: number }; + /** The world's archetypes created or bumped since the layout directory's last flush (`layout-directory.ts`). */ + readonly bumped: Archetype[]; + /** Whether this archetype is in `bumped`. */ + listed: boolean; +}; + +/** Moves the version on once arrays are replaced, and lists the archetype for the layout directory's next flush. */ +const bump = (archetype: Archetype): void => { + archetype.version++; + archetype.replaced.count++; + if (archetype.listed) return; + archetype.listed = true; + archetype.bumped.push(archetype); +}; + +const initialCapacity = 16; + +/** The tick arrays of a storage without ticks. */ +const noTicks = new Float64Array(0); +/** The tags of a storage whose values the world's thread holds. */ +const noTags = new Uint32Array(0); + +/** + * Gives a storage tick arrays for `capacity` rows, every tick set to `tick`. A constant's value can't change, so its + * `changed` ticks are its `added` ticks: tags cost one tick per row. + */ +const allocateTicks = (memory: Memory, storage: Storage, capacity: number, tick: number): void => { + storage.ticked = true; + storage.added = allocateArray(memory, Float64Array, capacity).fill(tick); + storage.changed = storage.layout.kind === "constant" ? storage.added : allocateArray(memory, Float64Array, capacity).fill(tick); + storage.chunkAdded = allocateArray(memory, Float64Array, chunksFor(capacity)).fill(tick); + storage.chunkChanged = storage.layout.kind === "constant" ? storage.chunkAdded : allocateArray(memory, Float64Array, chunksFor(capacity)).fill(tick); +}; + +/** Views of a new column, for a linear layout's or a string's `capacity` rows; a string's handles start at 0. */ +const columnOf = (memory: Memory, layout: Layout, capacity: number): Views => { + if (layout.kind === "linear") return allocateViews(memory, capacity * layout.stride + columnSlack); + if (layout.kind !== "string") return emptyViews; + const views = allocateViews(memory, capacity * layout.stride + columnSlack); + views.u32.fill(0); + return views; +}; + +const createStorage = (memory: Memory, strings: StringStore | null, component: number, layout: Layout, capacity: number, ticked: boolean): Storage => { + const storage: Storage = { + component, + layout, + views: columnOf(memory, layout, capacity), + strings: layout.kind === "string" ? strings : null, + values: [], + ...writeOf(layout), + words: layout.kind === "linear" && (layout.stride & 3) === 0 ? layout.stride >> 2 : 0, + ticked: false, + added: noTicks, + changed: noTicks, + chunkAdded: noTicks, + chunkChanged: noTicks, + columnChanged: 0, + remote: null, + tags: noTags, + }; + if (ticked) allocateTicks(memory, storage, capacity, 0); + return storage; +}; + +/** + * Starts keeping ticks for a storage, as if every row was added and changed at `tick`, so a system registered now sees + * every row on its first run, as it would had the ticks always been kept. + */ +export const startTicks = (memory: Memory, archetype: Archetype, storage: Storage, tick: number): void => { + if (storage.ticked) return; + allocateTicks(memory, storage, archetype.capacity, tick); + bump(archetype); +}; + +/** + * Creates an archetype, listed in `bumped` for the layout directory's next flush; storages of components a joined + * thread fills, by `remote`, hold their tags. + */ +export const createArchetype = (memory: Memory, strings: StringStore | null, id: number, components: readonly number[], layouts: readonly Layout[], resource: boolean, meta: boolean, ticked: ReadonlySet, bumped: Archetype[], replaced: { count: number }, remote: readonly (MoveLog | undefined)[]): Archetype => { + const storages = components.map(component => createStorage(memory, strings, component, layouts[component], initialCapacity, resource || ticked.has(component))); + const storageById: (Storage | undefined)[] = []; + for (const storage of storages) storageById[storage.component] = storage; + const archetype: Archetype = { + id, + components, + storages, + storageOf: new Map(storages.map(storage => [storage.component, storage])), + storageById, + adding: [], + removing: [], + moves: [], + entities: allocateArray(memory, Float64Array, initialCapacity), + count: 0, + capacity: initialCapacity, + resource, + meta, + objects: storages.some(storage => storage.layout.kind === "object" || storage.layout.kind === "string"), + strings: storages.filter(storage => storage.layout.kind === "string"), + version: 0, + replaced, + bumped, + listed: true, + logs: null, + counted: false, + }; + bumped.push(archetype); + for (const storage of storages) { + const log = remote[storage.component]; + if (log !== undefined) makeRemote(memory, archetype, storage, log); + } + return archetype; +}; + +const growViews = (memory: Memory, storage: Storage, capacity: number, count: number): void => { + const { layout } = storage; + if (layout.kind !== "linear" && layout.kind !== "string") return; + const grown = allocateViews(memory, capacity * layout.stride + columnSlack); + grown.u8.set(storage.views.u8.subarray(0, count * layout.stride)); + if (layout.kind === "string") grown.u32.fill(0, count); + freeViews(memory, storage.views); + storage.views = grown; +}; + +const grow = (memory: Memory, archetype: Archetype, capacity = archetype.capacity * 2): void => { + const { count } = archetype; + for (const storage of archetype.storages) { + growViews(memory, storage, capacity, count); + if (storage.remote !== null) storage.tags = growArray(memory, storage.tags, Uint32Array, capacity, count); + if (!storage.ticked) continue; + const shared = storage.changed === storage.added; + storage.added = growArray(memory, storage.added, Float64Array, capacity, count); + storage.changed = shared ? storage.added : growArray(memory, storage.changed, Float64Array, capacity, count); + const chunks = chunksFor(capacity); + if (chunks > storage.chunkAdded.length) { + const used = storage.chunkAdded.length; + storage.chunkAdded = growArray(memory, storage.chunkAdded, Float64Array, chunks, used).fill(0, used); + storage.chunkChanged = shared ? storage.chunkAdded : growArray(memory, storage.chunkChanged, Float64Array, chunks, used).fill(0, used); + } + } + archetype.entities = growArray(memory, archetype.entities, Float64Array, capacity, count); + archetype.capacity = capacity; + bump(archetype); +}; + +/** Grows once to fit `needed` rows, so a sync point adding many doesn't double over and over. */ +export const ensureCapacity = (memory: Memory, archetype: Archetype, needed: number): void => { + if (needed <= archetype.capacity) return; + let capacity = archetype.capacity; + while (capacity < needed) capacity *= 2; + grow(memory, archetype, capacity); +}; + +/** Appends a row for `entity`, growing if full. Component values and ticks are left for the caller to write. */ +export const insertRow = (memory: Memory, archetype: Archetype, entity: number): number => { + if (archetype.count === archetype.capacity) grow(memory, archetype); + const row = archetype.count++; + archetype.entities[row] = entity; + return row; +}; + +/** Copies one element of `stride` bytes: by 32-bit words when it's a multiple of 4, which every GPU layout is. */ +const copyElement = (from: Views, fromRow: number, to: Views, toRow: number, stride: number): void => { + if ((stride & 3) === 0) { + const words = stride >> 2; + const source = fromRow * words; + const target = toRow * words; + for (let i = 0; i < words; i++) to.u32[target + i] = from.u32[source + i]; + return; + } + const source = fromRow * stride; + const target = toRow * stride; + for (let i = 0; i < stride; i++) to.u8[target + i] = from.u8[source + i]; +}; + +/** Copies one component's value, not its ticks, between rows of storages with the same layout. */ +export const copyValue = (from: Storage, fromRow: number, to: Storage, toRow: number): void => { + const { layout, words } = from; + if (words > 0) { + const source = from.views.u32; + const target = to.views.u32; + const s = fromRow * words; + const t = toRow * words; + for (let i = 0; i < words; i++) target[t + i] = source[s + i]; + } + else if (layout.kind === "linear") copyElement(from.views, fromRow, to.views, toRow, layout.stride); + // A string's record goes with its value: its handle moves, and isn't let go. + else if (layout.kind === "string") { + to.views.u32[toRow] = from.views.u32[fromRow]; + to.values[toRow] = from.values[fromRow]; + } + else if (layout.kind === "object") { + if (to.remote === null) to.values[toRow] = from.values[fromRow]; + else to.tags[toRow] = from.tags[fromRow]; + } +}; + +/** Copies one component's value and ticks between rows of storages with the same layout. */ +export const copyCell = (from: Storage, fromRow: number, to: Storage, toRow: number): void => { + copyValue(from, fromRow, to, toRow); + // A component keeps ticks in every archetype or in none. + if (!to.ticked) return; + const added = from.added[fromRow]; + const changed = from.changed[fromRow]; + to.added[toRow] = added; + to.changed[toRow] = changed; + // A chunk's tick is the newest of its rows, so a row moving in can raise it. + const chunk = toRow >> chunkShift; + if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; + if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; +}; + +/** Removes a row by moving the last row into it. Returns the moved entity, or -1 if the removed row was last. */ +export const swapRemove = (archetype: Archetype, row: number): number => { + const last = --archetype.count; + if (row === last && !archetype.objects) return -1; + const moved = row === last ? -1 : archetype.entities[last]; + for (const storage of archetype.storages) { + if (moved !== -1) copyCell(storage, last, storage, row); + if (storage.layout.kind === "object" && storage.remote === null) storage.values[last] = undefined; + // A string's record went with the row's entity or was let go; the row past the count holds none. + else if (storage.layout.kind === "string") { + storage.values[last] = undefined; + storage.views.u32[last] = 0; + } + } + if (moved !== -1) archetype.entities[row] = moved; + return moved; +}; + +/** + * Gives a storage's objects to the joined thread `log` is for: its values are released, every row starting there at + * its start, and its tags are kept in their place. Allocates, so it runs only between runs. + */ +export const makeRemote = (memory: Memory, archetype: Archetype, storage: Storage, log: MoveLog): void => { + storage.remote = log; + storage.tags = allocateArray(memory, Uint32Array, archetype.capacity).fill(startTag); + storage.values.length = 0; + if (archetype.logs === null) archetype.logs = [log]; + else if (!archetype.logs.includes(log)) archetype.logs.push(log); + if (!log.archetypes.includes(archetype)) log.archetypes.push(archetype); + bump(archetype); +}; + +/** + * Takes a storage's objects back from a joined thread that's gone: each row at the value its tag names, `null` or its + * start, since a slot's value was the thread's. Allocates, so it runs only between runs. + */ +export const makeLocal = (memory: Memory, archetype: Archetype, storage: Storage, start: unknown): void => { + const log = storage.remote; + if (log === null) return; + for (let row = 0; row < archetype.count; row++) storage.values[row] = storage.tags[row] === nullTag ? null : start; + freeArray(memory, storage.tags); + storage.tags = noTags; + storage.remote = null; + if (!archetype.storages.some(other => other.remote === log)) { + const logs = (archetype.logs ?? []).filter(other => other !== log); + archetype.logs = logs.length === 0 ? null : logs; + log.archetypes.splice(log.archetypes.indexOf(archetype), 1); + } + bump(archetype); +}; diff --git a/packages/ecs/src/implementation/world/arrays.test.ts b/packages/ecs/src/implementation/world/arrays.test.ts new file mode 100644 index 000000000..f8d62db73 --- /dev/null +++ b/packages/ecs/src/implementation/world/arrays.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { allocateArray, growArray } from "./arrays.js"; +import { createMemory } from "./memory.js"; + +describe("arena arrays", () => { + it("views stay valid and distinct across memory growth", () => { + const memory = createMemory(); + const small = allocateArray(memory, Float64Array, 4); + small.set([1, 2, 3, 4]); + const large = allocateArray(memory, Float32Array, 1 << 20); + large[large.length - 1] = 5; + const grown = growArray(memory, small, Float64Array, 1 << 18, 4); + expect([...grown.subarray(0, 4)]).toEqual([1, 2, 3, 4]); + expect(large[large.length - 1]).toBe(5); + expect(memory.memory.buffer.byteLength).toBeGreaterThanOrEqual(large.byteOffset + large.byteLength); + }); + + it("reuses a freed block of the same size class", () => { + const memory = createMemory(); + const first = allocateArray(memory, Uint32Array, 100); + growArray(memory, first, Uint32Array, 200, 100); + expect(allocateArray(memory, Uint32Array, 120).byteOffset).toBe(first.byteOffset); + }); +}); diff --git a/packages/ecs/src/implementation/world/arrays.ts b/packages/ecs/src/implementation/world/arrays.ts new file mode 100644 index 000000000..b91d090d1 --- /dev/null +++ b/packages/ecs/src/implementation/world/arrays.ts @@ -0,0 +1,43 @@ +import { viewsOf, type Views } from '../../interface/registry/layout.js'; +import type { Memory } from './memory.js'; + +export type ArrayConstructor = { + new(buffer: ArrayBufferLike, byteOffset: number, length: number): T; + readonly BYTES_PER_ELEMENT: number; +}; + +type View = { readonly byteOffset: number; readonly byteLength: number }; + +/** A typed array over a new arena block. Contents are unspecified. */ +export const allocateArray = (memory: Memory, constructor: ArrayConstructor, length: number): T => { + if (!memory.shared) return new constructor(new ArrayBuffer(length * constructor.BYTES_PER_ELEMENT), 0, length); + // Allocate first: it may grow memory, which replaces `memory.buffer`. + const offset = memory.allocate(length * constructor.BYTES_PER_ELEMENT); + return new constructor(memory.memory.buffer, offset, length); +}; + +export const freeArray = (memory: Memory, array: View): void => { + if (memory.shared) memory.free(array.byteOffset, array.byteLength); +}; + +/** Moves the first `used` elements into a new block of `length` and frees the old one. */ +export const growArray = ): void; subarray(start: number, end: number): ArrayLike }>( + memory: Memory, array: T, constructor: ArrayConstructor, length: number, used: number, +): T => { + const grown = allocateArray(memory, constructor, length); + grown.set(array.subarray(0, used)); + freeArray(memory, array); + return grown; +}; + +/** Views of a new arena block of `bytes`, for one component's interleaved column. Contents are unspecified. */ +export const allocateViews = (memory: Memory, bytes: number): Views => { + if (!memory.shared) return viewsOf(new ArrayBuffer(bytes), 0, bytes); + const offset = memory.allocate(bytes); + return viewsOf(memory.memory.buffer, offset, bytes); +}; + +export const freeViews = (memory: Memory, views: Views): void => freeArray(memory, views.u8); + +/** Views of no bytes, for components with nothing to store. */ +export const emptyViews: Views = viewsOf(new ArrayBuffer(0), 0, 0); diff --git a/packages/ecs/src/implementation/world/async-loading.test.ts b/packages/ecs/src/implementation/world/async-loading.test.ts new file mode 100644 index 000000000..106012a22 --- /dev/null +++ b/packages/ecs/src/implementation/world/async-loading.test.ts @@ -0,0 +1,101 @@ +import { expect, it } from "vitest"; +import { Component, Entity, Finalizer, Resource, Rule, System, World } from "../../index.js"; +import { Observe } from "../../observe/index.js"; +import { createWorld } from "../index.js"; + +const meshSource = Symbol("asyncLoading/meshSource"); +const hidden = Symbol("asyncLoading/hidden"); +const meshSlot = Symbol("asyncLoading/meshSlot"); +const meshReady = Symbol("asyncLoading/meshReady"); +const nextToken = Symbol("asyncLoading/nextToken"); +const requests = Symbol("asyncLoading/requests"); + +type Request = { readonly entity: Entity; readonly token: number; readonly path: string }; + +declare module "@adobe/ecs" { + interface Components { + [meshSource]: { type: string }; + [hidden]: { type: true }; + /** The token of the slot's load; a new one each time the slot starts over. */ + [meshSlot]: { type: number; derived: true }; + /** The loaded mesh's handle, which exists only while its slot does. */ + [meshReady]: { type: number; within: typeof meshSlot }; + } + interface Resources { + [nextToken]: { type: number }; + [requests]: { type: readonly { readonly entity: Entity; readonly token: number; readonly path: string }[] }; + } +} + +Component.register(meshSource, { type: { type: "string" } }); +Component.register(hidden, { type: { const: true } }); +Component.register(meshSlot, { type: { type: "integer", default: 0 }, derived: true }); +Component.register(meshReady, { type: { type: "integer" }, within: meshSlot }); +Resource.register(nextToken, { type: { type: "integer", default: 1 } }); +Resource.register(requests, { + type: { + type: "array", + items: { type: "object", properties: { entity: Entity.schema, token: { type: "integer" }, path: { type: "string" } } }, + default: [], + }, +}); + +/** Meshes loaded outside the world, by handle: as GPU buffers are. */ +const loaded = new Map(); +let nextHandle = 1; +const load = async (path: string): Promise => { + loaded.set(nextHandle, path); + return nextHandle++; +}; + +// 1. A rule derives a slot for every visible entity with a mesh source. +Rule.create({ name: "asyncLoading/slots", components: { has: [meshSource], not: [hidden] }, derive: [meshSlot] }); + +// 2. Each new slot gets a fresh token, and its load is requested; the application starts requested loads. +const request = System.create({ + name: "asyncLoading/request", + components: { read: [meshSource], write: [meshSlot], added: [meshSlot] }, + resources: { write: [nextToken, requests] }, +}, (row, { resources }) => { + const token = resources[nextToken]; + resources[nextToken] = token + 1; + row[meshSlot] = token; + resources[requests] = [...resources[requests], { entity: row.entity, token, path: row[meshSource] }]; +}); + +// 3. A completed load arrives as a run of its own. A slot whose token still matches gets the mesh; a stale one releases it. +const apply = System.create({ + name: "asyncLoading/apply", + input: { type: "object", properties: { entity: Entity.schema, token: { type: "integer" }, handle: { type: "integer" } } }, + lookup: { slots: { read: [meshSlot] } }, +}, ({ input, lookup, commands }) => { + if (lookup.slots(input.entity)?.[meshSlot] === input.token) commands.add(input.entity, meshReady, input.handle); + else loaded.delete(input.handle); +}); + +// 4. When the slot goes, its mesh goes with it, and the finalizer releases what was loaded. +Finalizer.create({ name: "asyncLoading/release", components: { read: [meshReady] } }, row => { loaded.delete(row[meshReady]); }); + +const spawn = System.create({ name: "asyncLoading/spawn" }, ({ commands }) => { + commands.spawn([meshSource])({ [meshSource]: "ship.glb" }); + commands.spawn([meshSource])({ [meshSource]: "rock.glb" }); +}); +const hide = System.create({ name: "asyncLoading/hide", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { + commands.add(input.entity, hidden); +}); + +it("loads resources asynchronously for derived slots, and releases them when the slot goes", async () => { + await using world = createWorld(); + await world.run(spawn); + await world.run(request); + const [ship, rock] = await Observe.toPromise(Observe.resource(world, requests)); + // The rock is hidden before its load completes, so its result is stale when it arrives. + await world.run(hide, { entity: rock.entity }); + for (const { entity, token, path } of [ship, rock]) await world.run(apply, { entity, token, handle: await load(path) }); + const ready = await Observe.toPromise(Observe.select(world, { required: [meshReady] })); + expect(await Promise.all(ready.map(async entity => loaded.get((await Observe.toPromise(Observe.entity(world, entity, { required: [meshReady] })))?.[meshReady] ?? 0)))).toEqual(["ship.glb"]); + expect([...loaded.values()]).toEqual(["ship.glb"]); + await world.run(hide, { entity: ship.entity }); + expect(await Observe.toPromise(Observe.count(world, { required: [meshReady] }))).toBe(0); + expect(loaded.size).toBe(0); +}); diff --git a/packages/ecs/src/implementation/world/attach-kernel.test.ts b/packages/ecs/src/implementation/world/attach-kernel.test.ts new file mode 100644 index 000000000..8a214b05c --- /dev/null +++ b/packages/ecs/src/implementation/world/attach-kernel.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from "vitest"; + +it("rejects a kernel lookup of a string, which kernels don't read, where the kernel is declared", async () => { + await expect(import("./kernel-lookup.test-helper.js")).rejects.toThrow(`Kernel "kernelLookupTest/labels": a lookup reads "kernelLookupTest/label", a string, which kernels don't read yet.`); +}); + +it("rejects a compiled kernel that doesn't match its schemas where the kernel is declared", async () => { + await expect(import("./kernel-path.test-helper.js")).rejects.toThrow(`Kernel "kernelPathTest/beyond": "kernelPathTest/pair" has no scalar at [2]; destructure tuples and structs or index their fields.`); +}); diff --git a/packages/ecs/src/implementation/world/attach-kernel.ts b/packages/ecs/src/implementation/world/attach-kernel.ts new file mode 100644 index 000000000..da5f7f0ae --- /dev/null +++ b/packages/ecs/src/implementation/world/attach-kernel.ts @@ -0,0 +1,24 @@ +import { definitionOf, labelOfItem } from '../../interface/registry/index.js'; +import type { System } from '../../interface/system/system.js'; +import { checkKernel } from './check-kernel.js'; +import type { KernelDescription } from './kernel.js'; + +/** By the system `System.create` returned, so a lookup doesn't depend on which copy of the package defined it. */ +const kernels = new WeakMap, KernelDescription>(); + +/** + * The compiler's only hook into the runtime: wraps each kernel system's `System.create` call. Throws for a kernel that + * doesn't match its declaration and schemas (`checkKernel`), and for a key that's a list, which a command names as one + * component only where its build couldn't read the name's module. + */ +export const attachKernel = >(system: S, description: KernelDescription): S => { + for (const key of description.keys) { + if (Array.isArray(key)) throw new Error(`System "${labelOfItem(system)}" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); + } + const leaf = definitionOf(system); + if (leaf !== undefined && leaf.kind !== "composite") checkKernel(leaf, description); + kernels.set(system, description); + return system; +}; + +export const kernelOf = (system: System): KernelDescription | undefined => kernels.get(system); diff --git a/packages/ecs/src/implementation/world/batches.test.ts b/packages/ecs/src/implementation/world/batches.test.ts new file mode 100644 index 000000000..027815aab --- /dev/null +++ b/packages/ecs/src/implementation/world/batches.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { System } from "../../interface/system/system.js"; +import { count, read, readEach, readEntity } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; +import { Observe } from "../../observe/index.js"; +import { createBuffer } from "./buffer.js"; +import { createCommands } from "./commands.js"; +import { createState } from "./state.js"; +import { runReported } from "./reports.test-helper.js"; + +type Vec3 = readonly [number, number, number]; +const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; +const position = Symbol("batchesTest/position"); +const score = Symbol("batchesTest/score"); +const name = Symbol("batchesTest/name"); +const tag = Symbol("batchesTest/tag"); +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number, number] }; + [score]: { type: number }; + [name]: { type: string }; + [tag]: { type: true }; + } +} +Component.register(position, { type: vec3 }); +Component.register(score, { type: { type: "number", precision: 1 } }); +Component.register(name, { type: { type: "string" } }); +Component.register(tag, { type: { const: true } }); + +describe("staged spawns", () => { + it("are seen once by `added` observers in earlier, the same and later tiers, and not before the sync point", async () => { + const seen = { early: 0, same: 0, late: 0 }; + const spawned: Entity[] = []; + const early = System.create({ name: "batchesTest/early", components: { added: [score] } }, () => { seen.early++; }); + const spawn = System.create({ name: "batchesTest/spawn", after: [early] }, ({ commands }) => { + if (spawned.length > 0) return; + for (let i = 0; i < 100; i++) spawned.push(commands.spawn([position, score, name, tag])({ [position]: [i, i, i], [score]: i, [name]: `${i}`, [tag]: true })); + }); + const same = System.create({ name: "batchesTest/same", components: { added: [score] }, after: [early] }, () => { seen.same++; }); + const late = System.create({ name: "batchesTest/late", components: { added: [score] }, after: [spawn, same] }, () => { seen.late++; }); + const frame = System.create({ name: "batchesTest/edit", systems: [early, spawn, same, late] }); + const world = createWorld({ [workers]: 0 }); + await world.run(frame); + // Nothing saw the spawns during their own run. + expect(seen).toEqual({ early: 0, same: 0, late: 0 }); + await world.run(frame); + await world.run(frame); + expect(seen).toEqual({ early: 100, same: 100, late: 100 }); + expect(await Promise.all(spawned.map(entity => readEntity(world, entity, [position, score, name, tag])))).toEqual( + spawned.map((_, i) => ({ [position]: [i, i, i], [score]: i, [name]: `${i}`, [tag]: true }))); + await world.dispose(); + }); + + it("can be changed, removed, re-added or despawned in the run that spawns them", async () => { + const spawned: Entity[] = []; + const edit = System.create({ name: "batchesTest/edit/2" }, ({ commands }) => { + for (let i = 0; i < 4; i++) spawned.push(commands.spawn([score, name])({ [score]: i, [name]: `${i}` })); + commands.add(spawned[0], score, 10); + commands.remove(spawned[1], name); + commands.remove(spawned[2], name); + commands.add(spawned[2], name, "again"); + commands.despawn(spawned[3]); + }); + const world = createWorld({ [workers]: 0 }); + await world.run(edit); + expect(await Promise.all(spawned.map(async entity => [await read(world, entity, score), await read(world, entity, name)]))).toEqual([[10, "0"], [1, null], [2, "again"], [null, null]]); + expect(await count(world, { required: [score] })).toBe(3); + await world.dispose(); + }); + + it("keep the spawns before a value that can't be written, and release that one's entity", async () => { + const spawned: Entity[] = []; + let poison = true; + const spawn = System.create({ name: "batchesTest/spawn/2" }, ({ commands }) => { + for (let i = 0; i < 3; i++) spawned.push(commands.spawn([position, score])({ [position]: [i, 0, 0], [score]: i })); + // A symbol can't convert to a number, so writing it throws. + if (poison) commands.spawn([position, score])({ [position]: [0, 0, 0], [score]: Symbol("x") as unknown as number }); + }); + const world = createWorld({ [workers]: 0 }); + expect((await runReported(world, spawn)).failures).toHaveLength(1); + expect(await readEach(world, spawned, score)).toEqual([0, 1, 2]); + poison = false; + await world.run(spawn); + expect(await count(world, { required: [score] })).toBe(6); + expect(await readEach(world, spawned, score)).toEqual([0, 1, 2, 0, 1, 2]); + await world.dispose(); + }); + + it("take value objects built before the spawn", async () => { + const spawned: Entity[] = []; + const spawn = System.create({ name: "batchesTest/spawn/4" }, ({ commands }) => { + for (let i = 0; i < 20; i++) { + const values = i % 2 === 0 ? { [position]: [i, 0, 1] as const, [score]: i, [name]: `${i}` } : { [score]: i, [position]: [i, 0, 1] as const, [name]: `${i}` }; + spawned.push(commands.spawn([position, score, name])(values)); + } + }); + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + expect(await Promise.all(spawned.map(entity => readEntity(world, entity, [position, score, name])))).toEqual( + spawned.map((_, i) => ({ [position]: [i, 0, 1], [score]: i, [name]: `${i}` }))); + await world.dispose(); + }); +}); + +describe("spawns keyed by symbols", () => { + it("are staged into a batch, not queued as commands", () => { + const state = createState(); + const buffer = createBuffer(); + const commands = createCommands(state, { buffer }); + commands.spawn([score, tag])({ [score]: 1 }); + expect(buffer.count).toBe(0); + expect(state.batches.map(batch => batch.count)).toEqual([1]); + }); + it("read only their components' values, as adds do", async () => { + let spawned = Entity.none; + const edit = System.create({ name: "batchesTest/stringKeys" }, ({ commands }) => { + const values = { [score]: 1, "batchesTest/score": 2, [name]: "a" }; + spawned = commands.spawn([score])(values); + commands.add(spawned, [score], values); + }); + await using world = createWorld({ [workers]: 0 }); + await world.run(edit); + expect(await Observe.toPromise(Observe.entity(world, spawned, { optional: [position, score, name, tag] }))).toEqual({ [score]: 1 }); + }); +}); diff --git a/packages/ecs/src/implementation/world/batches.ts b/packages/ecs/src/implementation/world/batches.ts new file mode 100644 index 000000000..077c05258 --- /dev/null +++ b/packages/ecs/src/implementation/world/batches.ts @@ -0,0 +1,126 @@ +import { ensureCapacity, type Archetype } from './archetype.js'; +import type { CommandBuffer } from './buffer.js'; +import { indexOf } from './entities.js'; +import { chunkShift } from './kernel.js'; +import { createBatch, type SpawnBatch } from './staging.js'; +import { archetypeOf, type State } from './state.js'; +import { gainsOf } from './structure.js'; +import { remoteError } from './remote.js'; +import { countChanged } from './layout-directory.js'; +import { logRange, logSet, startTag, tagOf } from './move-log.js'; +import { writeDefault } from './synchronize.js'; +import { writeString } from './strings.js'; + +/** Batches of at most this many rows commit element by element. */ +const smallCopy = 8; + +/** The batch of main-thread spawns of exactly these components, made on first use. */ +export const batchOf = (state: State, ids: readonly number[]): SpawnBatch => { + const key = [...ids].sort((a, b) => a - b).join(","); + let batch = state.batchByKey.get(key); + if (batch === undefined) { + batch = createBatch(state.layouts, ids); + for (const column of batch.columns) if (state.remote[column.id] !== undefined) column.remote = remoteError(state, column.id).message; + state.batchByKey.set(key, batch); + state.batches.push(batch); + } + return batch; +}; + +/** + * Copies rows `first` to `first + n` of a batch into its archetype, or into its closure under `close` when rules or + * `within` apply, places their entities, writes the defaults of derived components the target gains, and stamps their + * ticks with the sync point's tick, as a commanded spawn would be. `born` records the first row each target gains. + */ +const commitRows = (state: State, batch: SpawnBatch, first: number, n: number, tick: number, born: Map, close: ((archetype: Archetype) => Archetype) | null): void => { + const spawned = batch.archetype ?? (batch.archetype = archetypeOf(state, batch.ids)); + const target = close === null ? spawned : close(spawned); + ensureCapacity(state.memory, target, target.count + n); + const start = target.count; + if (!born.has(target.id)) born.set(target.id, start); + if (close !== null) { + for (const id of gainsOf(state.structure, state.derived, null, target)) { + const storage = target.storageById[id]; + if (storage !== undefined) for (let row = start; row < start + n; row++) writeDefault(state, storage, row, tick); + } + } + // A few rows copy element by element: a subarray to copy from costs more than they do. + if (n <= smallCopy) for (let i = 0; i < n; i++) target.entities[start + i] = batch.entities[first + i]; + else target.entities.set(batch.entities.subarray(first, first + n), start); + if (target.logs !== null) for (const log of target.logs) logRange(log, target.id, start, n, batch.entities, first); + if (state.counting) countChanged(state.counted, target); + for (const column of batch.columns) { + const storage = target.storageById[column.id]; + if (storage === undefined) continue; + if (column.layout.kind === "linear") { + const bytes = n * column.stride; + const from = first * column.stride; + const offset = start * column.stride; + if (n <= smallCopy) for (let i = 0; i < bytes; i++) storage.views.u8[offset + i] = column.views.u8[from + i]; + else storage.views.u8.set(column.views.u8.subarray(from, from + bytes), offset); + } + else if (column.layout.kind === "object") { + if (storage.remote === null) for (let i = 0; i < n; i++) storage.values[start + i] = column.values[first + i]; + else { + // A joined thread's objects start at their starts there, as the rows' range says: a tag set otherwise. + const log = storage.remote; + const begin = log.starts[column.id]; + for (let i = 0; i < n; i++) { + const tag = Math.max(startTag, tagOf(column.values[first + i], begin)); + storage.tags[start + i] = tag; + if (tag !== startTag) logSet(log, target.id, start + i, column.id, tag, true); + } + } + column.values.fill(undefined, first, first + n); + } + // Rows past the count hold no record, so nothing is let go. + else if (column.layout.kind === "string") { + for (let i = 0; i < n; i++) writeString(storage, start + i, column.values[first + i]); + column.values.fill(undefined, first, first + n); + } + if (!storage.ticked) continue; + storage.added.fill(tick, start, start + n); + if (storage.changed !== storage.added) storage.changed.fill(tick, start, start + n); + // Ticks only increase, so the newest tick in each chunk the rows reach is this one. + storage.chunkAdded.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); + if (storage.chunkChanged !== storage.chunkAdded) storage.chunkChanged.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); + } + const { entities } = state; + for (let i = 0; i < n; i++) { + const index = indexOf(batch.entities[first + i]); + entities.archetype[index] = target.id; + entities.row[index] = start + i; + } + target.count += n; + state.touched?.add(target); +}; + +/** At a sync point, before the plan: commits and empties every main-thread batch. Returns the entities committed. */ +export const commitBatches = (state: State, tick: number, born: Map, close: ((archetype: Archetype) => Archetype) | null): number => { + let committed = 0; + for (const batch of state.batches) { + const n = batch.count; + if (n === 0) continue; + commitRows(state, batch, 0, n, tick, born, close); + batch.count = 0; + committed += n; + } + return committed; +}; + +/** + * Then, in merge order, the kernel spawns each queued buffer staged; their runner or worker empties the batches before + * staging into them again. Returns the entities committed. + */ +export const commitStaged = (state: State, queued: readonly { readonly buffer: CommandBuffer }[], tick: number, born: Map, close: ((archetype: Archetype) => Archetype) | null): number => { + let committed = 0; + for (let b = 0; b < queued.length; b++) { + const { buffer } = queued[b]; + for (let s = 0; s < buffer.stagedCount; s++) { + const { batch, start, end } = buffer.staged[s]; + commitRows(state, batch, start, end - start, tick, born, close); + committed += end - start; + } + } + return committed; +}; diff --git a/packages/ecs/src/implementation/world/buffer.ts b/packages/ecs/src/implementation/world/buffer.ts new file mode 100644 index 000000000..41eeed38e --- /dev/null +++ b/packages/ecs/src/implementation/world/buffer.ts @@ -0,0 +1,145 @@ +import { viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; +import type { KernelCommands } from './kernel.js'; +import type { Positional, SpawnBatch } from './staging.js'; + +export const spawnCommand = 0; +export const despawnCommand = 1; +export const addCommand = 2; +export const removeCommand = 3; + +/** Rows `start` to `end` of a kernel's spawn batch, committed in its buffer's place in merge order. */ +export type Staged = { batch: SpawnBatch; start: number; end: number }; + +/** + * Commands as parallel arrays with explicit counts, reused between runs, so issuing a command allocates nothing. + * Command `c` names components `ids[starts[c]]` up to the next command's start, with `values` alongside (unused for + * `remove`). A buffer drained from a worker's command ring keeps its structs' values as bytes in the ring, in their + * storages' layout, at `offsets` into `bytes`. A kernel's spawns are staged in batches instead, and its buffer lists + * their rows in `staged`, which the sync point commits before any command. + */ +export type CommandBuffer = { + count: number; + kinds: Uint8Array; + /** By command: the list of components a list add or remove names, by its index among the world's lists, or -1. */ + shapes: Int32Array; + entities: Float64Array; + starts: Int32Array; + /** Components named so far, across commands. */ + named: number; + ids: Int32Array; + readonly values: unknown[]; + /** For a drained buffer, where its values' bytes are; null for a buffer of values alone. */ + bytes: Views | null; + /** With `bytes`, by component named: its value's first byte there, or -1 for a value in `values`. */ + offsets: Int32Array; + /** The first `stagedCount` entries are this buffer's; the rest are kept for reuse. */ + readonly staged: Staged[]; + stagedCount: number; +}; + +export const createBuffer = (capacity = 16): CommandBuffer => ({ + count: 0, + kinds: new Uint8Array(capacity), + shapes: new Int32Array(capacity), + entities: new Float64Array(capacity), + starts: new Int32Array(capacity), + named: 0, + ids: new Int32Array(capacity * 4), + values: [], + bytes: null, + offsets: new Int32Array(0), + staged: [], + stagedCount: 0, +}); + +/** No commands; never written. */ +export const emptyBuffer: CommandBuffer = createBuffer(0); + +export const clearBuffer = (buffer: CommandBuffer): void => { + // Releases values for collection; the arrays keep their storage for the next run. + buffer.values.fill(undefined, 0, buffer.named); + buffer.bytes = null; + buffer.count = 0; + buffer.named = 0; + buffer.stagedCount = 0; +}; + +/** Adds rows `start` to `end` of a batch to the buffer's staged spawns. */ +export const stage = (buffer: CommandBuffer, batch: SpawnBatch, start: number, end: number): void => { + const entry = buffer.staged[buffer.stagedCount]; + if (entry === undefined) buffer.staged.push({ batch, start, end }); + else { + entry.batch = batch; + entry.start = start; + entry.end = end; + } + buffer.stagedCount++; +}; + +const grown = (array: T, create: (length: number) => T): T => { + const larger = create(array.length * 2); + larger.set(array); + return larger; +}; + +/** Starts a command; name its components with `pushComponent` right after. */ +/** Room for more commands: outlined, so `pushCommand` stays small enough to inline into the systems issuing them. */ +const growCommands = (buffer: CommandBuffer): void => { + buffer.kinds = grown(buffer.kinds, length => new Uint8Array(length)); + buffer.shapes = grown(buffer.shapes, length => new Int32Array(length)); + buffer.entities = grown(buffer.entities, length => new Float64Array(length)); + buffer.starts = grown(buffer.starts, length => new Int32Array(length)); +}; + +export const pushCommand = (buffer: CommandBuffer, kind: number, entity: number): void => { + if (buffer.count === buffer.kinds.length) growCommands(buffer); + buffer.kinds[buffer.count] = kind; + buffer.shapes[buffer.count] = -1; + buffer.entities[buffer.count] = entity; + buffer.starts[buffer.count] = buffer.named; + buffer.count++; +}; + +export const pushComponent = (buffer: CommandBuffer, id: number, value: unknown): void => { + if (buffer.named === buffer.ids.length) buffer.ids = grown(buffer.ids, length => new Int32Array(length)); + buffer.ids[buffer.named] = id; + buffer.values[buffer.named] = value; + buffer.named++; +}; + +/** A component value read out of bytes in a general layout, copied to row 0 here first; grown for larger layouts. */ +let scratch = viewsOf(new ArrayBuffer(256), 0, 256); + +/** Component `k`'s value: as given, or decoded from its bytes. */ +export const valueAt = (buffer: CommandBuffer, k: number, layouts: readonly Layout[]): unknown => { + const { bytes } = buffer; + if (bytes === null || buffer.offsets[k] < 0) return buffer.values[k]; + const layout = layouts[buffer.ids[k]]; + if (layout.kind !== "linear") return undefined; + const { stride } = layout; + if (stride > scratch.u8.length) scratch = viewsOf(new ArrayBuffer(stride * 2), 0, stride * 2); + const offset = buffer.offsets[k]; + scratch.u8.set(bytes.u8.subarray(offset, offset + stride)); + return layout.read(scratch, 0); +}; + +/** Component `k`'s value as a reference, an entity: one f64 in its bytes. */ +export const referenceAt = (buffer: CommandBuffer, k: number): number => { + const { bytes } = buffer; + return bytes === null || buffer.offsets[k] < 0 ? Number(buffer.values[k]) : bytes.f64[buffer.offsets[k] >> 3]; +}; + +export const endOf = (buffer: CommandBuffer, command: number): number => + command + 1 < buffer.count ? buffer.starts[command + 1] : buffer.named; + +/** + * Commands for a kernel, writing straight into a buffer: `ids` maps the kernel's key indexes to component ids, and + * `starts` to what each starts at, written for an `undefined` value. `target.buffer` may be replaced between calls. + * `spawners` stage the kernel's spawns, by spawn list. + */ +export const createKernelCommands = (target: { readonly buffer: CommandBuffer }, ids: readonly number[], starts: readonly unknown[], spawners: readonly Positional[]): KernelCommands => ({ + spawners, + begin: (kind, entity) => pushCommand(target.buffer, kind, entity), + value: (key, value) => pushComponent(target.buffer, ids[key], value === undefined ? starts[key] : value), + component: key => pushComponent(target.buffer, ids[key], undefined), +}); diff --git a/packages/ecs/src/implementation/world/builtins.test.ts b/packages/ecs/src/implementation/world/builtins.test.ts new file mode 100644 index 000000000..2cf824d52 --- /dev/null +++ b/packages/ecs/src/implementation/world/builtins.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Mat4x4, Quat, Vec3 } from "../../interface/math/index.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +const position = Symbol("builtinsTest/position"); +const velocity = Symbol("builtinsTest/velocity"); +const bits = Symbol("builtinsTest/bits"); +const counts = Symbol("builtinsTest/counts"); +const sums = Symbol("builtinsTest/sums"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + [bits]: { type: Vec3.U32 }; + [counts]: { type: Vec3.U32 }; + [sums]: { type: Vec3.I32 }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(velocity, { type: Vec3.schema }); +Component.register(bits, { type: Vec3.U32.schema }); +Component.register(counts, { type: Vec3.U32.schema }); +Component.register(sums, { type: Vec3.I32.schema }); + +const spawn = System.create({ name: "builtinsTest/spawn" }, ({ commands }) => { + for (let i = 0; i < 64; i++) commands.spawn([position, velocity])({ [position]: [i, i % 5, -i], [velocity]: [1, 0.5 * (i % 3), 0.25] }); +}); + +const rotation = Quat.fromAxisAngle([0, 1, 0], 0.5); +const matrix = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); + +/** Rotates, transforms and offsets each position, with functions from three math namespaces inlined. */ +const transform = System.create({ name: "builtinsTest/transform", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { + const q = Quat.fromAxisAngle([0, 1, 0], 0.5); + const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); + row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); +}); + +const expected = (p: Vec3, v: Vec3): Vec3 => Vec3.add(Mat4x4.multiplyVec3(matrix, Quat.rotateVec3(rotation, p)), Vec3.normalize(v)); + +const bitsAt = (i: number): Vec3.U32 => [Math.imul(i + 1, 0x9E3779B9) >>> 0, (0xFFFFFFFF - i) >>> 0, i << 26 >>> 0]; +const sumsAt = (i: number): Vec3.I32 => [2 ** 31 - 1 - i, -(2 ** 31) + i, Math.imul(i, 65536)]; + +const spawnBits = System.create({ name: "builtinsTest/spawnBits" }, ({ commands }) => { + for (let i = 0; i < 64; i++) commands.spawn([bits, counts, sums])({ [bits]: bitsAt(i), [counts]: [0, 0, 0], [sums]: sumsAt(i) }); +}); + +/** Integer functions that wrap, and bit functions without loops. */ +const integerResults = (b: Vec3.U32, s: Vec3.I32): { readonly counts: Vec3.U32; readonly sums: Vec3.I32 } => ({ + counts: Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), b))), + sums: Vec3.I32.add(Vec3.I32.multiply(s, [65536, 3, 65536]), Vec3.I32.abs(Vec3.I32.negate(s))), +}); + +const integers = System.create({ name: "builtinsTest/integers", kernel: "cpu", components: { read: [bits], write: [counts, sums] } }, row => { + const b = row[bits]; + const s = row[sums]; + row[counts] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), b))); + row[sums] = Vec3.I32.add(Vec3.I32.multiply(s, [65536, 3, 65536]), Vec3.I32.abs(Vec3.I32.negate(s))); +}); + +describe("math in kernels", () => { + for (const wasm of [false, true]) { + it(`matches the functions called directly (${wasm ? "WASM" : "JS"})`, async () => { + const world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(spawn); + await world.run(transform); + const read = await readEach(world, await select(world, { required: [position] }), position); + const want = Array.from({ length: 64 }, (_, i) => expected([i, i % 5, -i], [1, 0.5 * (i % 3), 0.25])); + // Stored as f32, so compared at f32 precision. + expect(read.map(value => value?.map(Math.fround))).toEqual(want.map(value => value.map(Math.fround))); + await world.dispose(); + }); + } + for (const wasm of [false, true]) { + it(`wraps integers and counts bits as the functions do (${wasm ? "WASM" : "JS"})`, async () => { + const world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(spawnBits); + await world.run(integers); + const entities = await select(world, { required: [bits] }); + const want = Array.from({ length: 64 }, (_, i) => integerResults(bitsAt(i), sumsAt(i))); + expect(await readEach(world, entities, counts)).toEqual(want.map(value => value.counts)); + expect(await readEach(world, entities, sums)).toEqual(want.map(value => value.sums)); + await world.dispose(); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/cascade.ts b/packages/ecs/src/implementation/world/cascade.ts new file mode 100644 index 000000000..cc7b92dda --- /dev/null +++ b/packages/ecs/src/implementation/world/cascade.ts @@ -0,0 +1,144 @@ +import type { Archetype } from './archetype.js'; +import { readCell } from './cells.js'; +import { indexOf, locate } from './entities.js'; +import { commandValue, isMissing } from './finalizers.js'; +import { createHolders, fitHolders, forEachHolder, linkHolder, unlinkHolder } from './holders.js'; +import { closureOf, owner } from './structure.js'; +import { archetypeOf, withoutComponent, type State } from './state.js'; +import type { Queued } from './synchronize.js'; + +/** + * A reference value written in this sync point: by spawn or add commands, or a batch spawn. `holder`, `component` + * and `target` triples, checked once the plan's despawns are known. + */ +export type Written = number[]; + +/** The entry for a live entity, added unchanged if it has none; -1 for one that isn't alive or planned. */ +export type EntryOf = (entity: number) => number; + +/** The value an entity holds for `component` once the plan applies, or undefined if it won't hold one. */ +const finalValue = (state: State, queued: readonly Queued[], holder: number, component: number): unknown => { + const { plan, entities, archetypes } = state; + const entry = plan.slots[indexOf(holder)] ?? -1; + if (entry >= 0 && plan.entity[entry] === holder) { + if (plan.despawned[entry] === 1 || archetypes[plan.to[entry]].storageById[component] === undefined) return undefined; + const value = commandValue(state, queued, entry, component); + if (!isMissing(value)) return value; + } + const index = locate(entities, holder); + if (index < 0) return undefined; + const storage = archetypes[entities.archetype[index]].storageById[component]; + return storage === undefined ? undefined : readCell(storage, entities.row[index]); +}; + +/** Whether `target` is alive once the plan applies: live or spawned here, and not despawned. */ +const aliveAfter = (state: State, target: number): boolean => { + const { plan, entities } = state; + const index = indexOf(target); + const entry = index < plan.slots.length ? plan.slots[index] : -1; + if (entry >= 0 && plan.entity[entry] === target) return plan.despawned[entry] === 0; + return locate(entities, target) >= 0; +}; + +/** + * Despawns the holders that own a reference to an entity that is despawned or never lives, and removes `weak` + * references to one, transitively, before rules close the plan. A holder acts only if its value once the plan applies + * names the target, so a reference pointed elsewhere in the same sync point is left alone. + */ +export const cascadeReferences = (state: State, queued: readonly Queued[], written: Written, entryOf: EntryOf): void => { + const { plan, structure, archetypes } = state; + const despawned: number[] = []; + for (let entry = 0; entry < plan.count; entry++) if (plan.despawned[entry] === 1) despawned.push(plan.entity[entry]); + const act = (holder: number, component: number): void => { + const entry = entryOf(holder); + if (entry < 0 || plan.despawned[entry] === 1) return; + if (structure.references[component] === owner) { + plan.despawned[entry] = 1; + despawned.push(holder); + return; + } + const to = withoutComponent(state, archetypes[plan.to[entry]], component); + plan.to[entry] = closureOf(structure, state.derived, to, components => archetypeOf(state, components), archetypes).id; + }; + let checked = 0; + for (let next = 0; next < despawned.length || checked < written.length;) { + for (; next < despawned.length; next++) { + const target = despawned[next]; + for (const component of structure.referenceIds) { + const lists = structure.holders[component]; + if (lists === undefined) continue; + forEachHolder(lists, target, holder => { + if (finalValue(state, queued, holder, component) === target) act(holder, component); + }); + } + } + // A reference written here to an entity that won't be alive is as dangling as one whose target despawns. + for (; checked < written.length; checked += 3) { + const holder = written[checked]; + const component = written[checked + 1]; + const target = written[checked + 2]; + // `Entity.none` names no entity, so a reference to it isn't dangling. + if (target !== 0 && finalValue(state, queued, holder, component) === target && !aliveAfter(state, target)) act(holder, component); + } + } +}; + +/** + * The changes the plan makes to the holder index, read before it applies, while old and new values are both readable: + * `remove` and `add` as target, holder, component triples. A despawned target's holders each have a removal of their + * own, since every reference component despawns its holders or drops the reference. + */ +export type IndexChanges = { readonly remove: number[]; readonly add: number[] }; + +export const indexChangesOf = (state: State, queued: readonly Queued[], fromOf: (entry: number) => Archetype | null, bornRows: readonly (readonly [Archetype, number])[]): IndexChanges => { + const { plan, structure, entities } = state; + const changes: IndexChanges = { remove: [], add: [] }; + for (let entry = 0; entry < plan.count; entry++) { + const holder = plan.entity[entry]; + const from = fromOf(entry); + for (const component of structure.referenceIds) { + const storage = from?.storageById[component]; + const before = storage === undefined ? undefined : readCell(storage, entities.row[indexOf(holder)]); + const after = plan.despawned[entry] === 1 ? undefined : finalValue(state, queued, holder, component); + if (before === after) continue; + if (typeof before === "number" && before !== 0) changes.remove.push(before, holder, component); + if (typeof after === "number" && after !== 0) changes.add.push(after, holder, component); + } + } + // Batch spawns no command touched still hold the references they were spawned with. An entity is one f64. + const { slots } = plan; + for (const [archetype, first] of bornRows) { + for (const component of structure.referenceIds) { + const storage = archetype.storageById[component]; + if (storage === undefined) continue; + const { unitStride, unitOffset } = storage; + const values = storage.views.f64; + const rows = archetype.entities; + for (let row = first; row < archetype.count; row++) { + const holder = rows[row]; + const index = indexOf(holder); + if (index < slots.length && slots[index] >= 0) continue; + const value = values[row * unitStride + unitOffset]; + if (value !== 0) changes.add.push(value, holder, component); + } + } + } + return changes; +}; + +/** Applies index changes after the plan applies. */ +export const applyIndexChanges = (state: State, changes: IndexChanges): void => { + const { holders } = state.structure; + const { remove, add } = changes; + for (let c = 0; c < remove.length; c += 3) { + const lists = holders[remove[c + 2]]; + if (lists !== undefined) unlinkHolder(lists, remove[c], remove[c + 1]); + } + const length = state.entities.length; + for (let c = 0; c < add.length; c += 3) { + const component = add[c + 2]; + const lists = holders[component] ??= createHolders(); + fitHolders(lists, length); + linkHolder(lists, add[c], add[c + 1]); + } +}; diff --git a/packages/ecs/src/implementation/world/cells.ts b/packages/ecs/src/implementation/world/cells.ts new file mode 100644 index 000000000..69a68e138 --- /dev/null +++ b/packages/ecs/src/implementation/world/cells.ts @@ -0,0 +1,24 @@ +import type { Storage } from './archetype.js'; +import { readString, writeString } from './strings.js'; + +/** Where a value is: a storage, or a joined thread's view of one. */ +export type Cell = Pick; + +/** A component value, copied out of linear storage. */ +export const readCell = (storage: Cell, row: number): unknown => { + const { layout } = storage; + switch (layout.kind) { + case "linear": return layout.read(storage.views, row); + case "string": return readString(storage, row); + case "object": return storage.values[row]; + default: return layout.value; + } +}; + +/** Replaces a component value. Doesn't touch ticks. */ +export const writeCell = (storage: Cell, row: number, value: unknown): void => { + const { layout } = storage; + if (layout.kind === "linear") layout.write(storage.views, row, value); + else if (layout.kind === "string") writeString(storage, row, value); + else if (layout.kind === "object") storage.values[row] = value; +}; diff --git a/packages/ecs/src/implementation/world/changes-finalizers.test.ts b/packages/ecs/src/implementation/world/changes-finalizers.test.ts new file mode 100644 index 000000000..569b2d9dc --- /dev/null +++ b/packages/ecs/src/implementation/world/changes-finalizers.test.ts @@ -0,0 +1,40 @@ +import { expect, it } from "vitest"; +import { Component, Finalizer, Resource, System, World } from "../../index.js"; +import { Observe } from "../../observe/index.js"; +import { createWorld } from "../index.js"; + +const name = Symbol("changesFinalizers/name"); +const score = Symbol("changesFinalizers/score"); +const journal = Symbol("changesFinalizers/journal"); + +declare module "@adobe/ecs" { + interface Components { + [name]: { type: string }; + [score]: { type: number }; + } + interface Resources { + [journal]: { type: readonly string[] }; + } +} + +Component.register(name, { type: { type: "string" } }); +Component.register(score, { type: { type: "number" } }); +Resource.register(journal, { type: { type: "array", items: { type: "string" }, default: [] } }); + +// A changes finalizer sees each sync point's structural changes to the components it watches, grouped by how +// they changed, with values before and after: for persistence, replication and undo. +Finalizer.changes({ name: "changesFinalizers/save", watch: [name, score], resources: { write: [journal] } }, (changes, { resources }) => { + const lines = changes.groups.flatMap(group => group.entities.map(entity => + `${changes.previous(entity, name) ?? changes.next(entity, name)}: +${group.added.length} -${group.removed.length} score ${changes.previous(entity, score)} → ${changes.next(entity, score)}`)); + resources[journal] = [...resources[journal], ...lines]; +}); + +const spawn = System.create({ name: "changesFinalizers/spawn" }, ({ commands }) => { commands.spawn([name, score])({ [name]: "ada", [score]: 3 }); }); +const retire = System.create({ name: "changesFinalizers/retire", components: { has: [score] } }, (row, { commands }) => { commands.remove(row.entity, score); }); + +it("records each sync point's structural changes", async () => { + await using world = createWorld(); + await world.run(spawn); + await world.run(retire); + expect(await Observe.toPromise(Observe.resource(world, journal))).toEqual(["ada: +2 -0 score null → 3", "ada: +0 -1 score 3 → null"]); +}); diff --git a/packages/ecs/src/implementation/world/check-kernel.ts b/packages/ecs/src/implementation/world/check-kernel.ts new file mode 100644 index 000000000..1bae5a80b --- /dev/null +++ b/packages/ecs/src/implementation/world/check-kernel.ts @@ -0,0 +1,86 @@ +import { declarationOf, labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { layoutOf, samePath, type Layout, type Path } from '../../interface/registry/layout.js'; +import type { Leaf } from '../../interface/system/leaf.js'; +import type { KernelDescription } from './kernel.js'; +import type { Key } from './state.js'; + +/** + * A key's layout, or null for one this copy of the package didn't register, as a library bundling its own copy + * registers its keys: a world binding the kernel checks those. + */ +const layoutOfKey = (key: Key): Layout | null => { + const declaration = declarationOf(key); + return declaration === undefined ? null : layoutOf(declaration.type); +}; + +/** Whether `path` names a scalar leaf of `layout`; true for an unknown layout. */ +const hasLeaf = (layout: Layout | null, path: Path): boolean => layout === null || (layout.kind === "linear" && layout.leaves.some(leaf => samePath(leaf.path, path))); + +/** Whether `leaves` are every scalar leaf of `layout`, each once; true for an unknown layout. */ +const exactly = (layout: Layout | null, leaves: readonly Path[]): boolean => { + if (layout === null) return true; + const own = layout.kind === "linear" ? layout.leaves : []; + return own.length === leaves.length && own.every(leaf => leaves.some(path => samePath(path, leaf.path))); +}; + +/** + * Checks a compiled kernel against its declaration and the registered schemas, which don't depend on a world, so + * registering the kernel rejects it where it's declared: every column, whole-value write, lookup, `up` read and + * reduction it uses is declared, and names scalar leaves of its component's schema, each spawn site names each of its + * components once, from its keys, and a tuple or struct a command or spawn gives goes to a component stored in linear + * memory. Throws with the first mismatch. + */ +export const checkKernel = (leaf: Leaf, description: KernelDescription): void => { + const { lookup, components: query, resources, up, reduce } = leaf.declaration; + const fail = (message: string): never => { throw new Error(`Kernel "${labelOfItem(leaf)}": ${message}`); }; + const queried = new Set([...query?.read ?? [], ...query?.write ?? []]); + const written = new Set(query?.write); + // A condition in `is` is readable too, and the compiler lists it with the reads. + const readable = new Set([...resources?.read ?? [], ...resources?.is ?? []]); + for (const key of description.resources) if (!readable.has(key)) fail(`reads resource "${labelOf(key)}", which isn't in resources.read.`); + for (const column of description.columns) { + if (column.kind === "entity") continue; + if (!queried.has(column.component)) fail(`"${labelOf(column.component)}" isn't in its components' read or write.`); + if (column.kind === "changed" || column.kind === "chunks") { + if (!written.has(column.component)) fail(`writes "${labelOf(column.component)}", which isn't in its components' write.`); + continue; + } + const layout = layoutOfKey(column.component); + if (layout !== null && layout.kind !== "linear") fail(`"${labelOf(column.component)}" isn't stored in linear memory.`); + if (!hasLeaf(layout, column.path)) fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + } + for (const shape of description.shapes) { + if (!written.has(shape.component)) fail(`writes "${labelOf(shape.component)}", which isn't in its components' write.`); + if (!exactly(layoutOfKey(shape.component), shape.leaves)) fail(`a whole-value write of "${labelOf(shape.component)}" doesn't match its schema.`); + } + for (const lookupName of description.lookups) if (lookup?.[lookupName] === undefined) fail(`unknown lookup "${lookupName}".`); + if ((description.up === true) !== (up !== undefined)) fail(description.up === true ? "reads up, which its declaration doesn't declare." : "its declaration declares up, which it doesn't read."); + const upReadable = new Set(up?.read); + for (const column of description.upColumns ?? []) { + if (column.kind !== "field" || !upReadable.has(column.component)) fail(`reads "${labelOf(column.kind === "field" ? column.component : column.kind)}" through up, which isn't in up.read.`); + if (column.kind !== "field") continue; + const layout = layoutOfKey(column.component); + if (layout !== null && layout.kind !== "linear") fail(`up reads "${labelOf(column.component)}", which isn't stored in linear memory.`); + if (!hasLeaf(layout, column.path)) fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + } + for (const reduced of description.reductions) { + if (Reflect.get(reduce ?? {}, reduced.resource) !== reduced.operator) fail(`reduces "${labelOf(reduced.resource)}" with "${reduced.operator}", which its reduce doesn't declare.`); + if (!hasLeaf(layoutOfKey(reduced.resource), reduced.path)) fail(`contributes to "${labelOf(reduced.resource)}" at [${reduced.path.join(", ")}], which isn't a scalar of its value; destructure tuples and structs or build them.`); + } + for (const list of description.spawns) { + list.forEach((k, position) => { + if (!Number.isInteger(k) || k < 0 || k >= description.keys.length) fail(`spawns a list naming component ${k} of its ${description.keys.length} keys.`); + if (list.findIndex(other => description.keys[other] === description.keys[k]) < position) fail(`spawns a list naming "${labelOf(description.keys[k])}" twice.`); + }); + } + for (const k of description.structuredValues) { + const key = description.keys[k]; + const layout = key === undefined ? null : layoutOfKey(key); + if (layout !== null && layout.kind !== "linear") fail(`gives "${labelOf(key)}" a tuple or struct, which only a component stored in linear memory takes.`); + } + for (const shape of description.reducedShapes) { + if (Reflect.get(reduce ?? {}, shape.component) === undefined || !exactly(layoutOfKey(shape.component), shape.leaves)) { + fail(`contributes a value to "${labelOf(shape.component)}" that doesn't match its schema.`); + } + } +}; diff --git a/packages/ecs/src/implementation/world/claims.test.ts b/packages/ecs/src/implementation/world/claims.test.ts new file mode 100644 index 000000000..9f339960c --- /dev/null +++ b/packages/ecs/src/implementation/world/claims.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { claimPiece, startRanges } from "./claims.js"; +import { lineWords } from "./protocol.js"; + +const drain = (words: Int32Array, self: number, participants: number): number[] => { + const claimed: number[] = []; + for (let piece = claimPiece(words, self, participants); piece >= 0; piece = claimPiece(words, self, participants)) claimed.push(piece); + return claimed; +}; + +describe("claims", () => { + it("take the home range in order, then steal from the back of the fullest", () => { + const words = new Int32Array(new SharedArrayBuffer(3 * lineWords * 4)); + startRanges(words, 3, 10); + // Ranges: 0..3, 3..6, 6..10. + expect(claimPiece(words, 1, 3)).toBe(3); + expect(claimPiece(words, 1, 3)).toBe(4); + expect(claimPiece(words, 1, 3)).toBe(5); + // Participant 2 has 4 left, more than participant 0's 3. + expect(claimPiece(words, 1, 3)).toBe(9); + expect(claimPiece(words, 1, 3)).toBe(2); + expect(drain(words, 0, 3)).toEqual([0, 1, 8, 7, 6]); + expect(claimPiece(words, 2, 3)).toBe(-1); + }); + + it("claim every piece exactly once however participants interleave", () => { + for (const [participants, pieces] of [[1, 1], [4, 3], [9, 72], [7, 100]]) { + const words = new Int32Array(new SharedArrayBuffer(participants * lineWords * 4)); + startRanges(words, participants, pieces); + const claimed: number[] = []; + let active = participants; + for (let turn = 0; active > 0; turn = (turn * 5 + 3) % participants) { + const piece = claimPiece(words, turn, participants); + if (piece < 0) active--; + else claimed.push(piece); + if (claimed.length > pieces) break; + } + expect(claimed.sort((a, b) => a - b)).toEqual(Array.from({ length: pieces }, (_, i) => i)); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/claims.ts b/packages/ecs/src/implementation/world/claims.ts new file mode 100644 index 000000000..f55a53584 --- /dev/null +++ b/packages/ecs/src/implementation/world/claims.ts @@ -0,0 +1,50 @@ +import { lineWords } from './protocol.js'; + +/** + * Pieces of a claimed split, as a home range per participant: one Int32 per participant, each on its own cache line, + * holding its next piece (`front`, low 16 bits) and the end of its range (`back`, high 16 bits). An owner takes pieces + * from its front, in order, so it streams forward through rows it ran last tier; a participant whose range is empty + * steals from the back of the fullest other range, so owner and thief meet only at the end. Every change is one + * compare-and-swap of the whole word, so a piece is claimed exactly once. + */ + +/** Split pieces into at most 32767, so a range's bounds fit its word. */ +export const maximumPieces = 0x7fff; + +const rangeWord = (front: number, back: number): number => (back << 16) | front; + +/** Gives each of `participants` an equal home range of `pieces`, in order. */ +export const startRanges = (words: Int32Array, participants: number, pieces: number): void => { + for (let i = 0; i < participants; i++) { + Atomics.store(words, i * lineWords, rangeWord(Math.floor(i * pieces / participants), Math.floor((i + 1) * pieces / participants))); + } +}; + +/** Claims the next piece for participant `self`: its own front, else the back of the fullest other range; -1 once none are left. */ +export const claimPiece = (words: Int32Array, self: number, participants: number): number => { + for (;;) { + const own = Atomics.load(words, self * lineWords); + const front = own & 0xffff; + const back = own >>> 16; + if (front < back) { + if (Atomics.compareExchange(words, self * lineWords, own, rangeWord(front + 1, back)) === own) return front; + continue; + } + let victim = -1; + let most = 0; + let word = 0; + for (let i = 0; i < participants; i++) { + if (i === self) continue; + const other = Atomics.load(words, i * lineWords); + const left = (other >>> 16) - (other & 0xffff); + if (left > most) { + most = left; + victim = i; + word = other; + } + } + if (victim < 0) return -1; + const last = (word >>> 16) - 1; + if (Atomics.compareExchange(words, victim * lineWords, word, rangeWord(word & 0xffff, last)) === word) return last; + } +}; diff --git a/packages/ecs/src/implementation/world/closure.test.ts b/packages/ecs/src/implementation/world/closure.test.ts new file mode 100644 index 000000000..68208a26f --- /dev/null +++ b/packages/ecs/src/implementation/world/closure.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { strataOf, type RuleFacts } from "../../interface/registry/strata.js"; +import { closedComponents } from "./closure.js"; + +// Components 0 to 9: 0 mesh, 1 transform, 2 hidden, 3 slot (derived), 4 ready (within slot), 5 visible (derived), 6 resident (derived), 7 sprite. +const derived = [false, false, false, true, false, true, true, false]; +const hosts = [-1, -1, -1, -1, 3, -1, -1, -1]; +const name = (id: number): string => `c${id}`; +const rule = (has: number[], not: number[], derives: number[]): RuleFacts => ({ has, not, derives }); +const closure = (rules: readonly RuleFacts[], components: number[]): number[] => closedComponents(components, derived, hosts, strataOf(rules, hosts, name)); + +describe("closure", () => { + const slots = rule([0, 1], [2], [3]); + it("derives where a rule matches, and drops what no rule derives", () => { + expect(closure([slots], [0, 1])).toEqual([0, 1, 3]); + expect(closure([slots], [0, 1, 2, 3])).toEqual([0, 1, 2]); + expect(closure([slots], [0, 3])).toEqual([0]); + }); + it("keeps a component within its host only while the host is present", () => { + expect(closure([slots], [0, 1, 3, 4])).toEqual([0, 1, 3, 4]); + expect(closure([slots], [0, 1, 2, 3, 4])).toEqual([0, 1, 2]); + }); + it("chains rules and unions owners, after the strata their negations read", () => { + const visible = rule([3], [], [5]); + const fromSprite = rule([7], [], [5]); + const resident = rule([5], [4], [6]); + expect(closure([slots, visible, fromSprite, resident], [0, 1])).toEqual([0, 1, 3, 5, 6]); + expect(closure([slots, visible, fromSprite, resident], [0, 1, 4])).toEqual([0, 1, 3, 4, 5]); + expect(closure([slots, visible, fromSprite, resident], [7])).toEqual([5, 6, 7]); + }); + it("is a least fixed point: positive cycles derive nothing on their own", () => { + expect(closure([rule([5], [], [6]), rule([6], [], [5])], [0])).toEqual([0]); + expect(closure([rule([5], [], [6]), rule([6], [], [5]), rule([0], [], [5])], [0])).toEqual([0, 5, 6]); + }); + it("rejects cycles through a negation or within", () => { + expect(() => strataOf([rule([], [6], [5]), rule([5], [], [6])], hosts, name)).toThrow("cycle through a negation, which could oscillate: c5, c6"); + expect(() => strataOf([rule([4], [], [3])], hosts, name)).toThrow("within each other in a cycle: c3, c4"); + }); +}); diff --git a/packages/ecs/src/implementation/world/closure.ts b/packages/ecs/src/implementation/world/closure.ts new file mode 100644 index 000000000..8d9347a0b --- /dev/null +++ b/packages/ecs/src/implementation/world/closure.ts @@ -0,0 +1,40 @@ +/** + * Structural derivation: which components an archetype's entities have once rules and `within` are applied. + * Datalog with stratified negation over the components each entity has. A rule adds the components it derives where + * its query matches; a component declared within a host stays only while the host is present. Each stratum is a least + * fixed point, evaluated after every stratum it depends on, so the result is a pure function of the archetype. + */ + +import type { Strata } from '../../interface/registry/strata.js'; + +/** + * The sorted components an entity with `components` has once derived: derived components only where a rule derives + * them, and components within a host only where the host is. + */ +export const closedComponents = (components: readonly number[], derived: readonly boolean[], hosts: readonly number[], strata: Strata): number[] => { + const present = new Set(); + const given = new Set(); + for (const id of components) { + if ((hosts[id] ?? -1) >= 0) given.add(id); + else if (derived[id] !== true) present.add(id); + } + for (const { rules, within } of strata) { + for (let changed = true; changed;) { + changed = false; + for (const rule of rules) { + if (!rule.has.every(id => present.has(id)) || rule.not.some(id => present.has(id))) continue; + for (const id of rule.derives) { + if (present.has(id)) continue; + present.add(id); + changed = true; + } + } + for (const { dependent, host } of within) { + if (present.has(dependent) || !given.has(dependent) || !present.has(host)) continue; + present.add(dependent); + changed = true; + } + } + } + return [...present].sort((a, b) => a - b); +}; diff --git a/packages/ecs/src/implementation/world/command-ring.ts b/packages/ecs/src/implementation/world/command-ring.ts new file mode 100644 index 000000000..ff33a6882 --- /dev/null +++ b/packages/ecs/src/implementation/world/command-ring.ts @@ -0,0 +1,474 @@ +import { viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; +import { emptyViews } from './arrays.js'; +import { writeF32, writeF32Lanes, writeF64, writeI32, writeOf, writeU32 } from './archetype.js'; +import { stage, type CommandBuffer } from './buffer.js'; +import { slotValue } from './move-log.js'; +import type { SpawnBatch } from './staging.js'; + +// A pool worker's command ring (threads.md, "Command rings"): segments a worker writes into shared memory for the world's +// thread to drain at the sync point. A segment holds one task's commands as the columns of a `CommandBuffer`, so the world +// drains it by taking views of them, then its staged spawn batches as blocks and the reservation ranges it left unused. +// +// Layout, every region on 8 bytes: a header (the merge tag, then counts and sizes, and its flags); entities, f64 by +// command; values' bytes; first components, i32 by command; component ids, i32 by component; offsets into the values' +// bytes, i32 by component, -1 for a value given by tag; kinds, u8 by command; tags, 16 bytes each (component, how, +// number); spawn blocks; unused reservation ranges, u32 pairs. + +const segmentRecord = 8; +const headerBytes = 56; + +/** + * A segment flag: some of its components are named by the joined thread's own numbers for their ids, `localNumbers` + * past each, which the world maps to its own as it drains it. + */ +const localFlag = 1; +/** Where a joined thread's own numbers for keys start among a segment's component ids. */ +export const localNumbers = 0x40000000; + +/** + * How a tagged component's value comes: its start, `null`, a number or a boolean, which the world resolves; or a value + * the issuing thread staged for its own object, by the slot's id. + */ +const startValue = 1; +const nullValue = 2; +const numberValue = 3; +const falseValue = 4; +const trueValue = 5; +const slotted = 6; + +const padded = (bytes: number): number => (bytes + 7) & ~7; + +/** A task's merge tag: its system's run, by the tick it took, and its chunk, by archetype and first row. */ +export type MergeTag = { readonly tick: number; readonly archetype: number; readonly start: number }; + +/** A task's segment, as the world's thread receives it: in the ring, or in a spill past the ring's size, and the merge tag the world gave the task. */ +export type Segment = { readonly bytes: Uint8Array; readonly tag: MergeTag }; + +/** How a worker writes one component's values: its layout, write kind and placement, and its bytes rounded up to 8. */ +export type Encoder = { readonly layout: Layout; readonly write: number; readonly unitOffset: number; readonly lanes: number; readonly stride: number; readonly padded: number }; + +export const encoderOf = (layout: Layout): Encoder => { + const { write, unitOffset, lanes } = writeOf(layout); + const stride = layout.kind === "linear" ? layout.stride : 0; + return { layout, write, unitOffset, lanes, stride, padded: padded(stride) }; +}; + +/** + * A spawn batch's object column holds this for a value left out, which the world resolves to the component's start. + * Only the worker that staged it reads it. + */ +export const objectStart = Symbol("objectStart"); + +/** One component's value in a general layout, written at row 0, to copy into the values' bytes; grown for larger layouts. */ +let scratch = viewsOf(new ArrayBuffer(256), 0, 256); +const scratchFor = (bytes: number): Views => { + if (bytes > scratch.u8.length) scratch = viewsOf(new ArrayBuffer(padded(bytes * 2)), 0, padded(bytes * 2)); + return scratch; +}; + +/** A task's commands as a worker builds them, in columns of its own reused by every task, copied into the ring at its end. */ +export type RingWriter = { + tag: MergeTag; + /** Whether a component is named by a joined thread's own number for it. */ + local: boolean; + count: number; + named: number; + kinds: Uint8Array; + entities: Float64Array; + firsts: Int32Array; + ids: Int32Array; + offsets: Int32Array; + /** Values' bytes, and the next free byte. */ + data: Views; + used: number; + /** Tagged values: by tag, the component it's at, how it comes and its number. */ + tagged: number; + tagEntries: Int32Array; + tagKinds: Int32Array; + tagNumbers: Float64Array; +}; + +const noTag: MergeTag = { tick: 0, archetype: 0, start: 0 }; + +export const createRingWriter = (): RingWriter => ({ + tag: noTag, local: false, count: 0, named: 0, + kinds: new Uint8Array(1024), entities: new Float64Array(1024), firsts: new Int32Array(1024), + ids: new Int32Array(1024), offsets: new Int32Array(1024), + data: viewsOf(new ArrayBuffer(8192), 0, 8192), used: 0, + tagged: 0, tagEntries: new Int32Array(256), tagKinds: new Int32Array(256), tagNumbers: new Float64Array(256), +}); + +const doubled = (array: T, create: (length: number) => T): T => { + const larger = create(array.length * 2); + larger.set(array); + return larger; +}; + +/** Starts a task's commands, with its merge tag. */ +export const startSegment = (writer: RingWriter, tag: MergeTag): void => { + writer.tag = tag; + writer.local = false; + writer.count = writer.named = writer.used = writer.tagged = 0; +}; + +/** Starts a despawn (1), add (2) or remove (3); its components follow. */ +export const beginCommand = (writer: RingWriter, kind: number, entity: number): void => { + const command = writer.count; + if (command === writer.kinds.length) { + writer.kinds = doubled(writer.kinds, length => new Uint8Array(length)); + writer.entities = doubled(writer.entities, length => new Float64Array(length)); + writer.firsts = doubled(writer.firsts, length => new Int32Array(length)); + } + writer.kinds[command] = kind; + writer.entities[command] = entity; + writer.firsts[command] = writer.named; + writer.count = command + 1; +}; + +/** A component of the open command, its value's offset or -1; returns its index. */ +const component = (writer: RingWriter, id: number, offset: number): number => { + if (id >= localNumbers) writer.local = true; + const named = writer.named; + if (named === writer.ids.length) { + writer.ids = doubled(writer.ids, length => new Int32Array(length)); + writer.offsets = doubled(writer.offsets, length => new Int32Array(length)); + } + writer.ids[named] = id; + writer.offsets[named] = offset; + writer.named = named + 1; + return named; +}; + +/** A component of the open remove. */ +export const putComponent = (writer: RingWriter, id: number): void => void component(writer, id, -1); + +const tag = (writer: RingWriter, entry: number, how: number, number: number): void => { + const tagged = writer.tagged; + if (tagged === writer.tagEntries.length) { + writer.tagEntries = doubled(writer.tagEntries, length => new Int32Array(length)); + writer.tagKinds = doubled(writer.tagKinds, length => new Int32Array(length)); + writer.tagNumbers = doubled(writer.tagNumbers, length => new Float64Array(length)); + } + writer.tagEntries[tagged] = entry; + writer.tagKinds[tagged] = how; + writer.tagNumbers[tagged] = number; + writer.tagged = tagged + 1; +}; + +/** A component of the open add left out: its start, which the world resolves. */ +export const putStart = (writer: RingWriter, id: number): void => tag(writer, component(writer, id, -1), startValue, 0); + +/** A component of the open add given `null`, which the world takes for an object it doesn't hold. */ +export const putNull = (writer: RingWriter, id: number): void => tag(writer, component(writer, id, -1), nullValue, 0); + +/** A component of the open add given a value the issuing thread staged for its own object, under slot `slot`. */ +export const putSlot = (writer: RingWriter, id: number, slot: number): void => tag(writer, component(writer, id, -1), slotted, slot); + +/** Drops the open command, naming no entity, as a value it gave can't cross. */ +export const dropCommand = (writer: RingWriter): void => { + if (writer.count > 0) writer.entities[writer.count - 1] = 0; +}; + +/** Writes a value of a linear layout at byte `at`: numbers and f32 lane tuples straight in, the rest through the layout. */ +const writeLinear = (views: Views, at: number, encoder: Encoder, value: unknown): void => { + const index = (at >> 2) + encoder.unitOffset; + switch (encoder.write) { + case writeF32: if (typeof value === "number") return void (views.f32[index] = value); break; + case writeU32: if (typeof value === "number") return void (views.u32[index] = value); break; + case writeI32: if (typeof value === "number") return void (views.i32[index] = value); break; + case writeF64: if (typeof value === "number") return void (views.f64[(at >> 3) + encoder.unitOffset] = value); break; + case writeF32Lanes: + if (Array.isArray(value)) { + for (let lane = 0; lane < encoder.lanes; lane++) views.f32[index + lane] = Number(value[lane]); + return; + } + } + const { layout, stride } = encoder; + if (layout.kind !== "linear") return; + const general = scratchFor(stride); + layout.write(general, 0, value); + views.u8.set(general.u8.subarray(0, stride), at); +}; + +/** What tag an object component's value takes, or -1 for one only the world's thread could hold. */ +const objectTag = (value: unknown): number => + value === objectStart || value === undefined ? startValue : value === null ? nullValue : typeof value === "number" ? numberValue + : value === false ? falseValue : value === true ? trueValue : -1; + +const unencodable = (id: number): Error => + new Error(`A kernel on a worker gave component ${id}, which isn't stored in linear memory, a value other than its start, null, a number or a boolean.`); + +/** A component of the open add and its value: a struct's bytes, or an object's tag. */ +export const putValue = (writer: RingWriter, id: number, encoder: Encoder, value: unknown): void => { + const { layout } = encoder; + if (layout.kind === "linear") { + const at = writer.used; + const end = at + encoder.padded; + if (end > writer.data.u8.length) { + const length = Math.max(end, writer.data.u8.length * 2); + const data = viewsOf(new ArrayBuffer(length), 0, length); + data.u8.set(writer.data.u8.subarray(0, at)); + writer.data = data; + } + writer.used = end; + component(writer, id, at); + writeLinear(writer.data, at, encoder, value); + return; + } + if (layout.kind === "constant") return putStart(writer, id); + const how = objectTag(value); + if (how < 0) throw unencodable(id); + tag(writer, component(writer, id, -1), how, typeof value === "number" ? value : 0); +}; + +/** + * For a joined thread's segments: stages a value its spawn gives one of its own objects, other than its start or + * `null`, returning the slot's id; -1 for any other value or component. + */ +export type Slots = (component: number, entity: number, value: unknown) => number; + +/** The bytes a spawn batch's block takes; throws for an object value the world can't take. */ +const batchBytes = (batch: SpawnBatch, slots: Slots | null): number => { + const { count, columns } = batch; + if (count === 0) return 0; + let bytes = 16 + padded(4 * columns.length) + 8 * count; + for (const column of columns) { + if (column.layout.kind === "linear") bytes += padded(count * column.stride); + // A string, which only the world's thread writes, crosses as its start, as another thread's object does. + else if (column.layout.kind === "object" || column.layout.kind === "string") { + // A joined thread's own objects take any value, staged as its segment is written. + if (slots === null) for (let row = 0; row < count; row++) if (objectTag(column.values[row]) < 0) throw unencodable(column.id); + bytes += 16 * count; + } + } + return bytes; +}; + +/** Copies a spawn batch's rows to byte `at` as one block, emptying it; returns the byte after it. */ +const putBatch = (views: Views, start: number, batch: SpawnBatch, slots: Slots | null): number => { + const { count, columns } = batch; + if (count === 0) return start; + const { u8, u32, f64 } = views; + let at = start; + u32[at >> 2] = count; + u32[(at >> 2) + 1] = columns.length; + at += 16; + for (let c = 0; c < columns.length; c++) u32[(at >> 2) + c] = columns[c].id; + at += padded(4 * columns.length); + f64.set(batch.entities.subarray(0, count), at >> 3); + at += 8 * count; + for (const column of columns) { + if (column.layout.kind === "linear") { + u8.set(column.views.u8.subarray(0, count * column.stride), at); + at += padded(count * column.stride); + } + else if (column.layout.kind === "object" || column.layout.kind === "string") { + for (let row = 0; row < count; row++, at += 16) { + const value = column.values[row]; + // A joined thread's own object holds what its spawn gave it, staged there. + const slot = slots === null ? -1 : slots(column.id, batch.entities[row], value); + const how = slot >= 0 ? slotted : objectTag(value); + if (how < 0) throw unencodable(column.id); + u32[at >> 2] = how; + f64[(at >> 3) + 1] = slot >= 0 ? slot : typeof value === "number" ? value : 0; + } + column.values.fill(undefined, 0, count); + } + } + batch.count = 0; + return at; +}; + +/** Where a task's segment is: its extent past the ring's base, or its spill and the spill's buffer. */ +export type Extent = { readonly start: number; readonly end: number; readonly spill: Uint8Array | null; readonly transfer: ArrayBuffer | null }; + +/** No segment, for a task that failed before starting one. */ +export const noExtent: Extent = { start: 0, end: 0, spill: null, transfer: null }; + +/** + * Writes the task's segment: its commands' columns, then `batches` as blocks, emptying them, and `unused`, into the ring + * at `cursor` past `base` when it fits before `capacity`, else into a spill of its own. + */ +export const endSegment = (writer: RingWriter, memory: Views, base: number, capacity: number, cursor: number, batches: readonly SpawnBatch[], unused: readonly number[], slots: Slots | null = null): Extent => { + const { count: n, named: m, tagged, used } = writer; + let spawnBytes = 0; + for (const batch of batches) spawnBytes += batchBytes(batch, slots); + const local = writer.local || batches.some(batch => batch.count > 0 && batch.ids.some(id => id >= localNumbers)); + const columns = 8 * n + padded(used) + padded(4 * n) + 2 * padded(4 * m) + padded(n); + const bytes = headerBytes + columns + 16 * tagged + spawnBytes + padded(4 * unused.length); + const inRing = cursor + bytes <= capacity; + const transfer = inRing ? null : new ArrayBuffer(bytes); + const views = transfer === null ? memory : viewsOf(transfer, 0, bytes); + const first = transfer === null ? base + cursor : 0; + const { u8, u32, i32, f64 } = views; + let at = first; + u32[at >> 2] = segmentRecord; + u32[(at >> 2) + 1] = writer.tag.archetype; + f64[(at >> 3) + 1] = writer.tag.tick; + f64[(at >> 3) + 2] = writer.tag.start; + u32[(at >> 2) + 6] = n; + u32[(at >> 2) + 7] = m; + u32[(at >> 2) + 8] = used; + u32[(at >> 2) + 9] = tagged; + u32[(at >> 2) + 10] = spawnBytes; + u32[(at >> 2) + 11] = unused.length; + u32[(at >> 2) + 12] = local ? localFlag : 0; + at += headerBytes; + f64.set(writer.entities.subarray(0, n), at >> 3); + at += 8 * n; + u8.set(writer.data.u8.subarray(0, used), at); + at += padded(used); + i32.set(writer.firsts.subarray(0, n), at >> 2); + at += padded(4 * n); + i32.set(writer.ids.subarray(0, m), at >> 2); + at += padded(4 * m); + i32.set(writer.offsets.subarray(0, m), at >> 2); + at += padded(4 * m); + u8.set(writer.kinds.subarray(0, n), at); + at += padded(n); + for (let t = 0; t < tagged; t++, at += 16) { + i32[at >> 2] = writer.tagEntries[t]; + i32[(at >> 2) + 1] = writer.tagKinds[t]; + f64[(at >> 3) + 1] = writer.tagNumbers[t]; + } + for (const batch of batches) at = putBatch(views, at, batch, slots); + for (let k = 0; k < unused.length; k++) u32[(at >> 2) + k] = unused[k]; + return transfer === null + ? { start: cursor, end: cursor + bytes, spill: null, transfer: null } + : { start: cursor, end: cursor, spill: new Uint8Array(transfer), transfer }; +}; + +const tagValue = (how: number, number: number, start: unknown): unknown => { + switch (how) { + case startValue: return start; + case nullValue: return null; + case numberValue: return number; + case falseValue: return false; + case trueValue: return true; + case slotted: return slotValue(number); + default: return undefined; + } +}; + +/** A spawn block's rows as a batch over the segment, to commit as the world thread's own. */ +const decodeBatch = (views: Views, at: number, layouts: readonly Layout[], starts: readonly unknown[]): { readonly batch: SpawnBatch; readonly end: number } => { + const { u32, f64 } = views; + const count = u32[at >> 2]; + const width = u32[(at >> 2) + 1]; + let next = at + 16; + const ids: number[] = []; + for (let c = 0; c < width; c++) ids.push(u32[(next >> 2) + c]); + next += padded(4 * width); + const { buffer, byteOffset } = views.u8; + const entities = new Float64Array(buffer, byteOffset + next, count); + next += 8 * count; + const columns = ids.map(id => { + const layout = layouts[id]; + const stride = layout.kind === "linear" ? layout.stride : 0; + let columnViews = emptyViews; + const values: unknown[] = []; + if (layout.kind === "linear") { + columnViews = viewsOf(buffer, byteOffset + next, count * stride); + next += padded(count * stride); + } + else if (layout.kind === "object" || layout.kind === "string") { + for (let row = 0; row < count; row++, next += 16) values.push(tagValue(u32[next >> 2], f64[(next >> 3) + 1], starts[id])); + } + return { id, layout, ...writeOf(layout), stride, views: columnViews, values, remote: null }; + }); + return { batch: { ids, archetype: null, columns, entities, count, capacity: count }, end: next }; +}; + +/** Read-only by every drained buffer: no command names a list. Grown to the most commands one segment held. */ +let noShapes = new Int32Array(0); + +/** A joined thread's own numbers for keys, by number, as the world's ids; -1 for a key the world never imported. */ +export type LocalIds = (number: number) => number; + +/** Maps the joined thread's own numbers among `ids` to the world's in place; false if one names a key it never imported. */ +const mapLocal = (ids: Int32Array | Uint32Array, from: number, count: number, local: LocalIds): boolean => { + for (let k = from; k < from + count; k++) { + if (ids[k] < localNumbers) continue; + const id = local(ids[k] - localNumbers); + if (id < 0) return false; + ids[k] = id; + } + return true; +}; + +/** The bytes a spawn block at byte `at` takes, by its header and its components' layouts. */ +const blockBytes = (u32: Uint32Array, at: number, layouts: readonly Layout[]): number => { + const count = u32[at >> 2]; + const width = u32[(at >> 2) + 1]; + let bytes = 16 + padded(4 * width) + 8 * count; + for (let c = 0; c < width; c++) { + const layout = layouts[u32[(at >> 2) + 4 + c]]; + if (layout === undefined) return -1; + if (layout.kind === "linear") bytes += padded(count * layout.stride); + else if (layout.kind === "object" || layout.kind === "string") bytes += 16 * count; + } + return bytes; +}; + +/** Maps the joined thread's own numbers among the spawn blocks' component ids, from byte `at` for `length` bytes. */ +const mapBatches = (u32: Uint32Array, at: number, length: number, layouts: readonly Layout[], local: LocalIds): boolean => { + for (let next = at; next < at + length;) { + if (!mapLocal(u32, (next >> 2) + 4, u32[(next >> 2) + 1], local)) return false; + const bytes = blockBytes(u32, next, layouts); + if (bytes < 0) return false; + next += bytes; + } + return true; +}; + +/** + * Drains a segment into `buffer`, whose columns become views of it: commands with their structs' bytes left in place, + * tagged values resolved, each spawn block staged as a batch over it, and unused reservation ranges appended to + * `unused`. A joined thread's own numbers for keys are mapped through `local`. The segment must start with `tag`; + * returns false if it doesn't, or names a key the world never imported, draining nothing. + */ +export const drainSegment = (buffer: CommandBuffer, bytes: Uint8Array, layouts: readonly Layout[], starts: readonly unknown[], unused: number[], tag: MergeTag, local: LocalIds | null = null): boolean => { + const views = viewsOf(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const { u32, i32, f64 } = views; + if (bytes.byteLength < headerBytes || u32[0] !== segmentRecord || u32[1] !== tag.archetype || f64[1] !== tag.tick || f64[2] !== tag.start) return false; + const n = u32[6]; + const m = u32[7]; + const used = u32[8]; + const tagged = u32[9]; + const spawnBytes = u32[10]; + const unusedCount = u32[11]; + if ((u32[12] & localFlag) !== 0 && (local === null || !mapLocal(i32, (headerBytes + 8 * n + padded(used) + padded(4 * n)) >> 2, m, local) || !mapBatches(u32, headerBytes + 8 * n + padded(used) + padded(4 * n) + 2 * padded(4 * m) + padded(n) + 16 * tagged, spawnBytes, layouts, local))) return false; + const { buffer: memory, byteOffset } = bytes; + let at = headerBytes; + buffer.entities = new Float64Array(memory, byteOffset + at, n); + at += 8 * n; + buffer.bytes = viewsOf(memory, byteOffset + at, used); + at += padded(used); + buffer.starts = new Int32Array(memory, byteOffset + at, n); + at += padded(4 * n); + buffer.ids = new Int32Array(memory, byteOffset + at, m); + at += padded(4 * m); + buffer.offsets = new Int32Array(memory, byteOffset + at, m); + at += padded(4 * m); + buffer.kinds = new Uint8Array(memory, byteOffset + at, n); + at += padded(n); + if (noShapes.length < n) noShapes = new Int32Array(Math.max(n, 2 * noShapes.length)).fill(-1); + buffer.shapes = noShapes; + buffer.count = n; + buffer.named = m; + // Values are read by every component, so the array covers them; only tagged ones hold any. + const { values } = buffer; + for (let k = values.length; k < m; k++) values.push(undefined); + for (let t = 0; t < tagged; t++, at += 16) { + const entry = i32[at >> 2]; + values[entry] = tagValue(i32[(at >> 2) + 1], f64[(at >> 3) + 1], starts[buffer.ids[entry]]); + } + const spawnEnd = at + spawnBytes; + while (at < spawnEnd) { + const { batch, end } = decodeBatch(views, at, layouts, starts); + stage(buffer, batch, 0, batch.count); + at = end; + } + for (let k = 0; k < unusedCount; k++) unused.push(u32[(at >> 2) + k]); + return true; +}; diff --git a/packages/ecs/src/implementation/world/command-rings.cases.ts b/packages/ecs/src/implementation/world/command-rings.cases.ts new file mode 100644 index 000000000..f119ce5ca --- /dev/null +++ b/packages/ecs/src/implementation/world/command-rings.cases.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./command-rings.fixture.js"; + +/** Workers' commands and spawns through their command rings: the cases, over the fixture built for node or from source. */ +export const commandRingsCases = (load: () => Promise) => describe("command rings", () => { + it("spill a task's commands past the ring's size in order, and grow the ring at the sync point", async () => { + const fixture = await load(); + const alone = await fixture.overflow(0); + for (const count of [1, 2, 7]) { + const { rows, first, second } = await fixture.overflow(count); + expect(rows).toEqual(alone.rows); + expect(first).toBeGreaterThan(0); + expect(second).toBe(first); + } + }); + + it("apply a failed task's commands and spawns from before its failure", async () => { + const fixture = await load(); + const alone = await fixture.failed(0); + const { failures, weights, extents, spawned } = alone; + expect(failures).toEqual(["commandRingsTest/failing"]); + // Every row but the first, which has no target, added its weight and spawned before reading its target; only + // the last one's target is none. + expect(weights).toEqual([null, ...Array.from({ length: 999 }, (_, i) => i + 1)]); + expect(extents.filter(value => value !== "null")).toHaveLength(998); + expect(spawned).toEqual(Array.from({ length: 999 }, (_, i) => i + 1)); + for (const count of [1, 2, 7]) expect(await fixture.failed(count)).toEqual(alone); + }); + + it("return the reservation positions a task's block left unused to the free list once", async () => { + const fixture = await load(); + for (const count of [0, 1, 2, 7]) expect(await fixture.returned(count)).toEqual({ spawned: 5 * 64 + 5_000, distinct: 5 * 64 + 5_000 }); + }); + + it("give object components their start, null or a number", async () => { + const fixture = await load(); + const rows = Array.from({ length: 1_000 }, (_, i) => i); + const alone = await fixture.objectValues(0); + expect(alone).toEqual([ + rows.map(() => "unnamed"), rows.map(() => null), rows, + rows.map(() => "unnamed"), rows.map(() => null), rows.map(() => null), + ]); + for (const count of [1, 2, 7]) expect(await fixture.objectValues(count)).toEqual(alone); + }); +}); diff --git a/packages/ecs/src/implementation/world/command-rings.fixture.ts b/packages/ecs/src/implementation/world/command-rings.fixture.ts new file mode 100644 index 000000000..05e7e1797 --- /dev/null +++ b/packages/ecs/src/implementation/world/command-rings.fixture.ts @@ -0,0 +1,189 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld } from "./create.js"; +import { countingWorkers } from "./platforms.test-helper.js"; +import { read, readEach, select } from "./reads.test-helper.js"; +import { runReported } from "./reports.test-helper.js"; + +/** Scenarios of workers' command rings against the main thread alone, run built in node and from source in chromium. */ + +const vec4 = { type: "array", items: { type: "number", precision: 1 }, minItems: 4, maxItems: 4 } as const satisfies Schema; + +const index = Symbol("commandRingsTest/index"); +const weight = Symbol("commandRingsTest/weight"); +const extent = Symbol("commandRingsTest/extent"); +const made = Symbol("commandRingsTest/made"); +const doomed = Symbol("commandRingsTest/doomed"); +const target = Symbol("commandRingsTest/target"); +const name = Symbol("commandRingsTest/name"); +const maybe = Symbol("commandRingsTest/maybe"); +const tally = Symbol("commandRingsTest/tally"); + +declare module "@adobe/ecs" { + export interface Components { + [index]: { type: number }; + [weight]: { type: number }; + [extent]: { type: readonly [number, number, number, number] }; + [made]: { type: number }; + [doomed]: { type: true }; + [target]: { type: Entity }; + [name]: { type: string | null }; + [maybe]: { type: string | null }; + [tally]: { type: number | null }; + } +} + +Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(weight, { type: { type: "number" } }); +Component.register(extent, { type: vec4 }); +Component.register(made, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(doomed, { type: { const: true } }); +Component.register(target, { type: Entity.schema }); +/** Objects: a nullable string starting at "unnamed", a nullable string, and a nullable number. */ +Component.register(name, { type: { anyOf: [{ type: "string" }, { type: "null" }], default: "unnamed" } }); +Component.register(maybe, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); +Component.register(tally, { type: { anyOf: [{ type: "number" }, { type: "null" }] } }); + +const seed = System.create({ name: "commandRingsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); +}); + +/** + * About 120 bytes of ring per row: two adds to the row, the later winning, a list add of a vec4, a spawn, and a despawn + * of every seventh spawn's row, so a run of a few thousand rows overflows a worker's ring. + */ +const burst = System.create({ + name: "commandRingsTest/burst", + kernel: "cpu", + input: { type: "object", properties: { shift: { type: "number" } } }, + components: { read: [index] }, +}, (row, { input, commands }) => { + const i = row[index]; + commands.add(row.entity, weight, -1); + commands.add(row.entity, weight, i + input.shift); + commands.add(row.entity, [extent, doomed], { [extent]: [i, i + 1, i + 2, input.shift] }); + const spawned = commands.spawn([made])({ [made]: i }); + if (i % 7 === 0) commands.despawn(spawned); +}); + +/** Every row's values and spawns, in a canonical order, since entity ids depend on reservation interleaving. */ +const burstState = async (world: World): Promise => { + const rows = await Promise.all((await select(world, { required: [index] })).map(entity => Promise.all([ + read(world, entity, index), read(world, entity, weight), read(world, entity, extent), read(world, entity, doomed), + ]))); + const spawned = await readEach(world, await select(world, { required: [made] }), made); + return [...rows.map(row => JSON.stringify(row)).sort(), JSON.stringify(spawned.sort((a, b) => Number(a) - Number(b)))]; +}; + +/** Using `world`, then disposing it. */ +const using = async (world: World, use: (world: World) => Promise): Promise => { + try { + return await use(world); + } + finally { + await world.dispose(); + } +}; + +/** + * `burst` over 8,000 rows twice, and the completions that spilled after each: the first run's rings overflow, and the + * sync point grows them to what the run needed, so the second spills nothing more. + */ +export const overflow = (count: number): Promise<{ readonly rows: readonly string[]; readonly first: number; readonly second: number }> => { + const counting = countingWorkers(); + return using(createWorld({ [workers]: count }, { minimumChunk: 64, pool: counting.platform }), async world => { + await world.run(seed, { count: 8_000 }); + await world.run(burst, { shift: 1 }); + const first = counting.spilled(); + await world.run(burst, { shift: 2 }); + return { rows: await burstState(world), first, second: counting.spilled() }; + }); +}; + +/** Rows targeting a live entity, but for the last, whose target never lived. */ +const targets = System.create({ name: "commandRingsTest/targets", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + const first = commands.spawn([index])({ [index]: 0 }); + for (let i = 1; i < input.count; i++) commands.spawn([index, target])({ [index]: i, [target]: i === input.count - 1 ? Entity.none : first }); +}); + +/** A spawn and an add for each row, then a read through its target, which throws at the last row, whose target is none. */ +const failing = System.create({ + name: "commandRingsTest/failing", + kernel: "cpu", + components: { read: [index, target] }, + lookup: { targets: { read: [index] } }, +}, (row, { lookup, commands }) => { + commands.spawn([made])({ [made]: row[index] }); + commands.add(row.entity, weight, row[index]); + const other = lookup.targets(row[target]); + // @ts-expect-error the last row's target is none, so reading through it throws, as a kernel failing partway does + commands.add(row.entity, extent, [other[index], 0, 0, 0]); +}); + +type Failed = { readonly failures: readonly string[]; readonly weights: readonly unknown[]; readonly extents: readonly string[]; readonly spawned: readonly unknown[] }; + +/** The run's failures, then every row's weight and extent, and the spawns, after `failing` throws at its last row. */ +export const failed = (count: number): Promise => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { + await world.run(targets, { count: 1_000 }); + const report = await runReported(world, failing); + const rows = await select(world, { required: [index] }); + const spawned = await readEach(world, await select(world, { required: [made] }), made); + return { + failures: report.failures.map(failure => failure.name), + weights: (await readEach(world, rows, weight)).sort((a, b) => Number(a) - Number(b)), + extents: (await readEach(world, rows, extent)).map(value => JSON.stringify(value)).sort(), + spawned: spawned.sort((a, b) => Number(a) - Number(b)), + }; +}); + +/** A spawn for every 64th row: each chunk uses one position of its block of eight and returns the rest. */ +const sparse = System.create({ + name: "commandRingsTest/sparse", + kernel: "cpu", + components: { read: [index], not: [made] }, +}, (row, { commands }) => { + if (row[index] % 64 === 0) commands.spawn([made])({ [made]: row[index] }); +}); + +const many = System.create({ name: "commandRingsTest/many" }, ({ commands }) => { + for (let i = 0; i < 5_000; i++) commands.spawn([made])({ [made]: 1_000_000 + i }); +}); + +/** + * Sparse spawns from workers over five runs, then 5,000 from the main thread, which reuse the positions blocks returned: + * how many entities have `made`, and how many of them are distinct. A position returned twice would be reserved twice. + */ +export const returned = (count: number): Promise<{ readonly spawned: number; readonly distinct: number }> => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { + await world.run(seed, { count: 4_096 }); + for (let run = 0; run < 5; run++) await world.run(sparse); + await world.run(many); + const entities = await select(world, { required: [made] }); + return { spawned: entities.length, distinct: new Set(entities).size }; +}); + +/** Object components from a kernel: left out, null, and a number, by add and by spawn. */ +const objects = System.create({ + name: "commandRingsTest/objects", + kernel: "cpu", + components: { read: [index] }, +}, (row, { commands }) => { + const i = row[index]; + commands.add(row.entity, [name, maybe, tally], { [maybe]: null, [tally]: i }); + commands.spawn([made, name, maybe, tally])({ [made]: i, [tally]: null }); +}); + +/** Each row's object components, then each spawn's, in index order. */ +export const objectValues = (count: number): Promise => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { + await world.run(seed, { count: 1_000 }); + await world.run(objects); + const rows = await select(world, { required: [index], order: { [index]: true } }); + const spawned = await select(world, { required: [made], order: { [made]: true } }); + return [ + await readEach(world, rows, name), await readEach(world, rows, maybe), await readEach(world, rows, tally), + await readEach(world, spawned, name), await readEach(world, spawned, maybe), await readEach(world, spawned, tally), + ]; +}); diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts new file mode 100644 index 000000000..d0a27afba --- /dev/null +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "vitest"; +import { Archetype } from "../../interface/archetype/archetype.js"; +import { Report } from "../../interface/report/report.js"; +import { Component } from "../../interface/component/component.js"; +import type { Essential } from "../../interface/component/value.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { Rule } from "../../interface/rule/rule.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import type { Commands } from "../../interface/system/commands.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { workers } from "../../interface/resources/workers.js"; +import { Observe } from "../../observe/index.js"; +import { count, read, readEntity, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +type Vec3 = readonly [number, number, number]; +const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; +const position = Symbol("commandsTest/position"); +const score = Symbol("commandsTest/score"); +const health = Symbol("commandsTest/health"); +const label = Symbol("commandsTest/label"); +const tag = Symbol("commandsTest/tag"); +const other = Symbol("commandsTest/other"); +const slot = Symbol("commandsTest/slot"); +const unregistered = Symbol("commandsTest/unregistered"); +const level = Symbol("commandsTest/level"); +const bit0 = Symbol("commandsTest/bit0"); +const bit1 = Symbol("commandsTest/bit1"); +const bit2 = Symbol("commandsTest/bit2"); +const bit3 = Symbol("commandsTest/bit3"); +const bits = [bit0, bit1, bit2, bit3] as const; +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number, number] }; + [score]: { type: number }; + [health]: { type: number }; + [label]: { type: string | null }; + [tag]: { type: true }; + [other]: { type: true }; + [slot]: { type: number; derived: true }; + [unregistered]: { type: number }; + [bit0]: { type: true }; + [bit1]: { type: true }; + [bit2]: { type: true }; + [bit3]: { type: true }; + } + export interface Resources { + [level]: { type: number }; + } +} +Component.register(position, { type: vec3 }); +Component.register(score, { type: { type: "number", precision: 1 } }); +Component.register(health, { type: { type: "number" } }); +Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); +Component.register(tag, { type: { const: true } }); +Component.register(other, { type: { const: true } }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Component.register(bit0, { type: { const: true } }); +Component.register(bit1, { type: { const: true } }); +Component.register(bit2, { type: { const: true } }); +Component.register(bit3, { type: { const: true } }); +Resource.register(level, { type: { type: "number", default: 0 } }); +Rule.create({ name: "commandsTest/slots", components: { has: [other, score] }, derive: [slot] }); + +const unit = Archetype.create([position, score, tag]); +const made = Archetype.create([score, tag]); +const marks = Archetype.create([other, label]); + +/** Every component of these tests an entity has, or `null` if it is dead. */ +const components = (world: World, entity: Entity) => + Observe.toPromise(Observe.entity(world, entity, { optional: [position, score, health, label, tag, other, slot, bit0, bit1, bit2, bit3] })); + +/** Spawns and adds with literal values, which the plugin passes positionally, from one site over several lists. */ +const literals: Entity[] = []; +const spawnLiterals = System.create({ name: "commandsTest/spawnLiterals" }, ({ commands }) => { + for (let i = 0; i < 6; i++) literals.push(commands.spawn(i % 3 === 0 ? defaulted : i % 3 === 1 ? made : [score, label])({ [score]: i })); +}); +const addLiterals = System.create({ name: "commandsTest/addLiterals", components: { has: [score], not: [health] } }, (row, { commands }) => { + commands.add(row.entity, [health, label], { [label]: "x", [health]: 5 }); +}); + +const spawnFrom = System.create({ name: "commandsTest/spawnFrom", kernel: "cpu", components: { read: [score], not: [tag, other] } }, (row, { commands }) => { + commands.spawn(made)({ [score]: row[score] + 10 }); +}); +const mark = System.create({ name: "commandsTest/mark", kernel: "cpu", components: { has: [tag], not: [other] } }, (row, { commands }) => { + commands.add(row.entity, marks, {}); +}); +const unmark = System.create({ name: "commandsTest/unmark", kernel: "cpu", components: { has: [other] } }, (row, { commands }) => { + commands.remove(row.entity, marks); +}); +const defaulted = Archetype.create([score, label, tag]); + +/** A world, and a function running `body` once in a system with the world's commands, rejecting with what it threw. */ +const withCommands = () => { + const world = createWorld({ [workers]: 0 }); + let body: (commands: Commands) => void = () => {}; + const issue = System.create({ name: "commandsTest/issue" }, ({ commands }) => body(commands)); + const run = async (next: (commands: Commands) => void): Promise => { + body = next; + const [failure] = (await runReported(world, issue)).failures; + if (failure !== undefined) throw new Error(failure.message); + }; + return { world, run }; +}; + +describe("spawn", () => { + it("spawns exactly the components listed, through Archetype.create, plain and inline lists", async () => { + const { world, run } = withCommands(); + const plain: readonly Essential[] = [score, tag]; + const spawned: Entity[] = []; + await run(commands => { + spawned.push(commands.spawn(unit)({ [position]: [1, 2, 3], [score]: 4 })); + spawned.push(commands.spawn(plain)({ [score]: 5 })); + spawned.push(commands.spawn([position, score])({ [position]: [6, 6, 6], [score]: 6 })); + }); + expect(await components(world, spawned[0])).toEqual({ [position]: [1, 2, 3], [score]: 4, [tag]: true }); + expect(await components(world, spawned[1])).toEqual({ [score]: 5, [tag]: true }); + expect(await components(world, spawned[2])).toEqual({ [position]: [6, 6, 6], [score]: 6 }); + await world.dispose(); + }); + it("starts a tag or a component whose type allows null, left out or given undefined, at true or null, never leaving it out", async () => { + const { world, run } = withCommands(); + const spawned: Entity[] = []; + await run(commands => { + spawned.push(commands.spawn(defaulted)({ [score]: 1 })); + spawned.push(commands.spawn(defaulted)({ [score]: 2, [label]: undefined, [tag]: undefined })); + spawned.push(commands.spawn(defaulted)({ [score]: 3, [label]: "a", [tag]: true })); + }); + expect(await Promise.all(spawned.map(entity => components(world, entity)))).toEqual([ + { [score]: 1, [label]: null, [tag]: true }, + { [score]: 2, [label]: null, [tag]: true }, + { [score]: 3, [label]: "a", [tag]: true }, + ]); + await world.dispose(); + }); + it("reads only the listed components' values", async () => { + const { world, run } = withCommands(); + let spawned = Entity.none; + await run(commands => { + spawned = Reflect.apply(commands.spawn([score]), undefined, [{ [score]: 1, [position]: [1, 1, 1], [tag]: true, "commandsTest/score": 2 }]); + }); + expect(await components(world, spawned)).toEqual({ [score]: 1 }); + await world.dispose(); + }); + it("gives the same spawner for a world and set of components, from any system, for a new list or another order", async () => { + const { world, run } = withCommands(); + const spawners: unknown[] = []; + await run(commands => { + spawners.push(commands.spawn(unit), commands.spawn(unit), commands.spawn([position, score, tag]), commands.spawn([score, tag, position])); + }); + await run(commands => { spawners.push(commands.spawn(unit)); }); + expect(spawners[1]).toBe(spawners[0]); + expect(spawners[2]).toBe(spawners[0]); + expect(spawners[4]).toBe(spawners[0]); + // A list in another order names the same components. + expect(spawners[3]).toBe(spawners[0]); + const another = withCommands(); + await another.run(commands => { spawners.push(commands.spawn(unit)); }); + expect(spawners[5]).not.toBe(spawners[0]); + await Promise.all([world.dispose(), another.world.dispose()]); + }); + it("spawns into more archetypes than spawns cache, each with its values", async () => { + const { world, run } = withCommands(); + const lists = Array.from({ length: 16 }, (_, set): (typeof score | (typeof bits)[number])[] => [score, ...bits.filter((_, bit) => (set & (1 << bit)) !== 0)]); + const archetypes = lists.map(list => Archetype.create([...list])); + const spawned: Entity[] = []; + await run(commands => { + for (let i = 0; i < 64; i++) spawned.push(commands.spawn(i % 2 === 0 ? archetypes[i % 16] : [...lists[i % 16]])({ [score]: i })); + }); + for (let i = 0; i < 64; i++) { + expect(Object.getOwnPropertySymbols(await components(world, spawned[i])).length).toBe(1 + bits.filter((_, bit) => ((i % 16) & (1 << bit)) !== 0).length); + expect(await read(world, spawned[i], score)).toBe(i); + } + await world.dispose(); + }); + it("throws at first use for a key that isn't a registered component a command may add, or one listed twice", async () => { + const { world, run } = withCommands(); + const tried = (list: readonly unknown[]) => run(commands => { Reflect.apply(commands.spawn, commands, [list]); }); + await expect(tried([score, unregistered])).rejects.toThrow(`"commandsTest/unregistered" has no registered schema`); + await expect(tried([score, slot])).rejects.toThrow("is derived, so only rules add and remove it"); + await expect(tried([level])).rejects.toThrow(`"commandsTest/level" is a resource, not a component`); + await expect(tried([score, score])).rejects.toThrow(`lists "commandsTest/score" twice`); + await expect(tried(["commandsTest/score"])).rejects.toThrow("a component's key is a symbol"); + // Nothing spawned but the failed runs' reports. + expect(await count(world, { not: [Report.run] })).toBe(0); + await world.dispose(); + }); +}); + +describe("literal values in a system's body", () => { + it("spawn and add through one site over several lists, starting what they leave out", async () => { + const world = createWorld({ [workers]: 0 }); + literals.length = 0; + await world.run(spawnLiterals); + expect(await Promise.all(literals.map(entity => components(world, entity)))).toEqual([0, 1, 2, 3, 4, 5].map(i => + i % 3 === 0 ? { [score]: i, [label]: null, [tag]: true } : i % 3 === 1 ? { [score]: i, [tag]: true } : { [score]: i, [label]: null })); + await world.run(addLiterals); + expect(await Promise.all(literals.map(entity => readEntity(world, entity, [score, health, label])))).toEqual([0, 1, 2, 3, 4, 5].map(i => ({ [score]: i, [health]: 5, [label]: "x" }))); + await world.dispose(); + }); +}); + +/** Three adds of one component to each row, by systems ordered first to third, the second adding twice. */ +const first = System.create({ name: "commandsTest/firstAdd", kernel: "cpu", components: { has: [position] } }, (row, { commands }) => { + commands.add(row.entity, score, 1); +}); +const second = System.create({ name: "commandsTest/secondAdd", components: { has: [position] }, after: [first] }, (row, { commands }) => { + commands.add(row.entity, score, 2); + commands.add(row.entity, score, 3); +}); +const third = System.create({ name: "commandsTest/thirdAdd", kernel: "cpu", components: { has: [position] }, after: [second] }, (row, { commands }) => { + commands.add(row.entity, score, 4); +}); + +describe("add and remove", () => { + it("add one component with its value, a tag without one, or a list with its values and defaults", async () => { + const { world, run } = withCommands(); + let entity = Entity.none; + await run(commands => { entity = commands.spawn([position])({ [position]: [0, 0, 0] }); }); + await run(commands => { + commands.add(entity, score, 2); + commands.add(entity, tag); + commands.add(entity, [health, label, other], { [health]: 100 }); + }); + expect(await components(world, entity)).toEqual({ [position]: [0, 0, 0], [score]: 2, [tag]: true, [health]: 100, [label]: null, [other]: true, [slot]: 0 }); + await run(commands => { commands.add(entity, [score, health], { [score]: 3, [health]: 4 }); }); + expect(await readEntity(world, entity, [score, health])).toEqual({ [score]: 3, [health]: 4 }); + await world.dispose(); + }); + it("apply in the order issued: of systems in schedule order, and in each by row, so a later add of a component wins", async () => { + const { world, run } = withCommands(); + let entity = Entity.none; + await run(commands => { entity = commands.spawn([position, score])({ [position]: [0, 0, 0], [score]: 0 }); }); + await world.run(System.create([third, first, second])); + expect(await read(world, entity, score)).toBe(4); + await world.run(System.create([second, first])); + expect(await read(world, entity, score)).toBe(3); + await world.dispose(); + }); + it("remove one component or a list", async () => { + const { world, run } = withCommands(); + let entity = Entity.none; + await run(commands => { entity = commands.spawn([position, score, tag, other, health])({ [position]: [0, 0, 0], [score]: 1, [health]: 100 }); }); + await run(commands => { commands.remove(entity, tag); }); + expect(await components(world, entity)).toEqual({ [position]: [0, 0, 0], [score]: 1, [other]: true, [health]: 100, [slot]: 0 }); + await run(commands => { commands.remove(entity, [other, health]); }); + expect(await components(world, entity)).toEqual({ [position]: [0, 0, 0], [score]: 1 }); + await world.dispose(); + }); + it("throw at first use for a key that isn't a component a command may add", async () => { + const { world, run } = withCommands(); + let entity = Entity.none; + await run(commands => { entity = commands.spawn([score])({ [score]: 1 }); }); + await expect(run(commands => { Reflect.apply(commands.add, commands, [entity, slot, 1]); })).rejects.toThrow("is derived"); + await expect(run(commands => { Reflect.apply(commands.add, commands, [entity, [score, unregistered], {}]); })).rejects.toThrow("has no registered schema"); + await expect(run(commands => { Reflect.apply(commands.remove, commands, [entity, [level]]); })).rejects.toThrow("is a resource, not a component"); + // A kernel's command names a list declared in another module as if it were one component. + await expect(run(commands => { Reflect.apply(commands.remove, commands, [entity, [[score]]]); })).rejects.toThrow("A command names a list of components where one component's symbol goes."); + expect(await components(world, entity)).toEqual({ [score]: 1 }); + await world.dispose(); + }); + it("move rows from several source archetypes in one sync point, keeping values, and stamping only what changed", async () => { + const { world, run } = withCommands(); + const spawned: Entity[] = []; + await run(commands => { + for (let i = 0; i < 40; i++) spawned.push(commands.spawn(i % 2 === 0 ? [position, score] : [position, score, other])({ [position]: [i, i, i], [score]: i })); + }); + const added: Entity[] = []; + const changed: Entity[] = []; + const watchAdded = System.create({ name: "commandsTest/watchAdded", components: { has: [score], added: [tag] } }, row => { added.push(row.entity); }); + const watchChanged = System.create({ name: "commandsTest/watchChanged", components: { has: [score], changed: [position] } }, row => { changed.push(row.entity); }); + const watch = System.create({ name: "commandsTest/watch", systems: [watchAdded, watchChanged] }); + await world.run(watch); + added.length = changed.length = 0; + await run(commands => { + for (let i = 0; i < 40; i++) { + if (i % 4 < 2) commands.add(spawned[i], tag); + if (i % 4 === 3) commands.add(spawned[i], position, [-i, -i, -i]); + if (i % 5 === 0) commands.remove(spawned[i], [score]); + } + }); + await world.run(watch); + const expected = spawned.filter((_, i) => i % 4 < 2 && i % 5 !== 0); + expect([...added].sort((a, b) => a - b)).toEqual(expected); + expect([...changed].sort((a, b) => a - b)).toEqual(spawned.filter((_, i) => i % 4 === 3 && i % 5 !== 0)); + for (let i = 0; i < 40; i++) { + expect(await read(world, spawned[i], position)).toEqual(i % 4 === 3 ? [-i, -i, -i] : [i, i, i]); + expect(await read(world, spawned[i], score)).toBe(i % 5 === 0 ? null : i); + expect(await read(world, spawned[i], tag)).toBe(i % 4 < 2 ? true : null); + } + expect(await select(world, { required: [tag, other] })).toEqual(expect.arrayContaining(spawned.filter((_, i) => i % 4 === 1))); + await world.dispose(); + }); +}); + +describe("spawners where code can't be generated", () => { + it("loop over the values", async () => { + const generate = globalThis.Function; + // A content security policy without 'unsafe-eval' makes `new Function` throw. + globalThis.Function = new Proxy(generate, { construct: () => { throw new EvalError("blocked"); } }); + try { + const { world, run } = withCommands(); + const spawned: Entity[] = []; + await run(commands => { + for (let i = 0; i < 3; i++) spawned.push(commands.spawn([position, score, label, bit3])({ [position]: [i, i, i], [score]: i })); + const values = { [score]: 9, [label]: "a" }; + spawned.push(commands.spawn([score, label])(values)); + }); + // A spawn with literal values in a system's body, which the plugin passes positionally. + const literal = System.create({ name: "commandsTest/literal" }, ({ commands }) => { + spawned.push(commands.spawn([score, bit2, label])({ [label]: "b", [score]: 8 })); + }); + await world.run(literal); + expect(await Promise.all(spawned.map(entity => components(world, entity)))).toEqual([ + ...[0, 1, 2].map(i => ({ [position]: [i, i, i], [score]: i, [label]: null, [bit3]: true })), + { [score]: 9, [label]: "a" }, + { [score]: 8, [label]: "b", [bit2]: true }, + ]); + await world.dispose(); + } + finally { + globalThis.Function = generate; + } + }); +}); + +describe("kernels", () => { + it("spawn, add and remove through Archetype.create bindings", async () => { + const { world, run } = withCommands(); + await run(commands => { for (let i = 0; i < 3; i++) commands.spawn([score])({ [score]: i }); }); + await world.run(spawnFrom); + const spawned = await select(world, { required: [score, tag] }); + expect(await Promise.all(spawned.map(entity => components(world, entity)))).toEqual([10, 11, 12].map(value => ({ [score]: value, [tag]: true }))); + await world.run(mark); + expect(await Promise.all(spawned.map(entity => components(world, entity)))).toEqual([10, 11, 12].map(value => ({ [score]: value, [tag]: true, [other]: true, [label]: null, [slot]: 0 }))); + await world.run(unmark); + expect(await count(world, { required: [other] })).toBe(0); + expect(await count(world, { required: [tag, score] })).toBe(3); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/commands.ts b/packages/ecs/src/implementation/world/commands.ts new file mode 100644 index 000000000..969940171 --- /dev/null +++ b/packages/ecs/src/implementation/world/commands.ts @@ -0,0 +1,78 @@ +import type { Commands } from '../../interface/system/commands.js'; +import { addCommand, despawnCommand, pushCommand, pushComponent, removeCommand, type CommandBuffer } from './buffer.js'; +import { listShapeOf, spawnerOf } from './lists.js'; +import { commandIdOf, idOf, type Key, type State } from './state.js'; + +const collect = (state: State, buffer: CommandBuffer, key: Key, value: unknown): void => { + if (value !== undefined) pushComponent(buffer, idOf(state, key), value); +}; + +/** + * Appends a command naming each own symbol key of `values` whose value isn't `undefined`, for the world's own changes + * to the resource entity. Ids are resolved first, so an unregistered key throws before anything is appended. + */ +export const pushValues = (state: State, buffer: CommandBuffer, kind: number, entity: number, values: object): void => { + const { count, named } = buffer; + pushCommand(buffer, kind, entity); + try { + const keys = Object.getOwnPropertySymbols(values); + for (let k = 0; k < keys.length; k++) collect(state, buffer, keys[k], Reflect.get(values, keys[k])); + } + catch (error) { + buffer.count = count; + buffer.named = named; + throw error; + } +}; + +/** Where a world's commands queue: on the commands object under a symbol, so prepared sites find it in one read. */ +export const commandsTarget = Symbol("commandsTarget"); + +export type CommandsTarget = { readonly state: State; readonly target: { readonly buffer: CommandBuffer } }; + +/** Values of a list add given none, which only an untyped caller does. */ +const empty = {}; + +/** + * Commands that queue for the next sync point. A key that isn't a component commands may name throws here, inside the + * issuing system, before anything is queued. Values are immutable by convention, so they aren't copied. + */ +export const createCommands = (state: State, target: { readonly buffer: CommandBuffer }): Commands & { readonly [commandsTarget]: CommandsTarget } => ({ + [commandsTarget]: { state, target }, + spawn: components => spawnerOf(state, listShapeOf(state, components)), + despawn: entity => { + pushCommand(target.buffer, despawnCommand, entity); + }, + add: (entity: number, components: symbol | readonly symbol[], values?: unknown) => { + const { buffer } = target; + if (typeof components === "symbol") { + const id = commandIdOf(state, components); + pushCommand(buffer, addCommand, entity); + pushComponent(buffer, id, values === undefined ? state.starts[id] : values); + return; + } + const shape = listShapeOf(state, components); + const { keys, ids, starts } = shape; + const given = typeof values === "object" && values !== null ? values : empty; + pushCommand(buffer, addCommand, entity); + buffer.shapes[buffer.count - 1] = shape.index; + for (let k = 0; k < keys.length; k++) { + const value: unknown = Reflect.get(given, keys[k]); + pushComponent(buffer, ids[k], value === undefined ? starts[k] : value); + } + }, + remove: (entity: number, components: symbol | readonly symbol[]) => { + const { buffer } = target; + if (typeof components === "symbol") { + const id = commandIdOf(state, components); + pushCommand(buffer, removeCommand, entity); + pushComponent(buffer, id, undefined); + return; + } + const shape = listShapeOf(state, components); + const { ids } = shape; + pushCommand(buffer, removeCommand, entity); + buffer.shapes[buffer.count - 1] = shape.index; + for (let k = 0; k < ids.length; k++) pushComponent(buffer, ids[k], undefined); + }, +}); diff --git a/packages/ecs/src/implementation/world/composite-input.test.ts b/packages/ecs/src/implementation/world/composite-input.test.ts new file mode 100644 index 000000000..1873c819f --- /dev/null +++ b/packages/ecs/src/implementation/world/composite-input.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +const seen: { readonly system: string; readonly input: unknown }[] = []; +const move = System.create({ + name: "compositeInputTest/move", + input: { type: "object", properties: { distance: { type: "number" } } }, +}, ({ input }) => { seen.push({ system: "move", input: { ...input } }); }); +const step = System.create({ + name: "compositeInputTest/step", + input: { type: "object", properties: { distance: { type: "number" }, dt: { type: "number" } } }, +}, ({ input }) => { seen.push({ system: "step", input: { ...input } }); }); +const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); +const label = System.create({ + name: "compositeInputTest/label", + input: { type: "object", properties: { distance: { type: "string" } } }, +}, ({ input }) => { void input.distance; }); + +describe("composite input", () => { + it("passes each member the fields of the run's input that it declares", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create({ name: "compositeInputTest/frame", systems: [move, step, idle] }), { distance: 1, dt: 0.5 }); + expect(seen).toEqual([{ system: "move", input: { distance: 1 } }, { system: "step", input: { distance: 1, dt: 0.5 } }]); + }); + + it("rejects a field two members declare with different schemas, naming both", () => { + // @ts-expect-error distance is a number in one and a string in the other + expect(() => System.create({ name: "compositeInputTest/conflict", systems: [move, label] })).toThrow('Systems "compositeInputTest/move" and "compositeInputTest/label" declare input field "distance" with different schemas.'); + }); + + it("rejects an input that isn't an object of named fields, for untyped declarations", () => { + // @ts-expect-error input is an object schema + expect(() => System.create({ name: "compositeInputTest/scalar", input: { type: "number" } }, () => {})).toThrow('System "compositeInputTest/scalar": input is an object schema of named fields.'); + }); +}); diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts new file mode 100644 index 000000000..dc3a2cc7b --- /dev/null +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import { read, readResource, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +const paused = Symbol("conditionsTest/paused"); +const level = Symbol("conditionsTest/level"); +const ticks = Symbol("conditionsTest/ticks"); +const after = Symbol("conditionsTest/after"); +const sizes = Symbol("conditionsTest/sizes"); +const total = Symbol("conditionsTest/total"); +const count = Symbol("conditionsTest/count"); +const value = Symbol("conditionsTest/value"); + +declare module "@adobe/ecs" { + interface Resources { + [paused]: { type: boolean }; + [level]: { type: { readonly size: number } | null }; + [ticks]: { type: number }; + [after]: { type: number }; + [sizes]: { type: number }; + [total]: { type: number; reduce: "+" }; + [count]: { type: number }; + } + interface Components { + [value]: { type: number }; + } +} + +Resource.register(paused, { type: { type: "boolean", default: false } }); +Resource.register(level, { type: { anyOf: [{ type: "object", properties: { size: { type: "number" } } }, { type: "null" }] } }); +Resource.register(ticks, { type: { type: "number", default: 0 } }); +Resource.register(after, { type: { type: "number", default: 0 } }); +Resource.register(sizes, { type: { type: "number", default: 0 } }); +Resource.register(count, { type: { type: "number", default: 0 } }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Component.register(value, { type: { type: "number" } }); + +const pause = System.create({ name: "conditionsTest/pause", resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); +const tick = System.create({ name: "conditionsTest/tick", resources: { not: [paused], write: [ticks] } }, ({ resources }) => { resources[ticks] += 1; }); +const afterTick = System.create({ name: "conditionsTest/afterTick", resources: { write: [after] }, after: [tick] }, ({ resources }) => { resources[after] += 1; }); +const frame = System.create({ name: "conditionsTest/frame", systems: [tick, afterTick] }); +const grow = System.create({ name: "conditionsTest/grow", kernel: "cpu", components: { write: [value] }, resources: { not: [paused] } }, row => { + row[value] = row[value] + 1; +}); +const growWhilePaused = System.create({ name: "conditionsTest/growWhilePaused", kernel: "cpu", components: { write: [value] }, resources: { is: [paused] } }, row => { + row[value] = row[value] + 1; +}); + +describe("resource conditions", () => { + it("skip a system while they don't hold, successfully, so systems after it still run", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(frame); + await world.run(pause); + await world.run(frame); + expect(await readResource(world, ticks)).toBe(1); + expect(await readResource(world, after)).toBe(2); + }); + + it("read an `is` condition as holding", async () => { + await using world = createWorld({ [workers]: 0 }); + const measure = System.create({ name: "conditionsTest/measure", resources: { is: [level], write: [sizes] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); + await world.run(measure); + expect(await readResource(world, sizes)).toBe(0); + const load = System.create({ name: "conditionsTest/load", resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 3 }; }); + await world.run(load); + await world.run(measure); + expect(await readResource(world, sizes)).toBe(3); + }); + + it("keep a skipped system's lastRun, so it sees every change once it runs again", async () => { + await using world = createWorld({ [workers]: 0 }); + const seen: number[] = []; + const watch = System.create({ name: "conditionsTest/watch", components: { read: [value], changed: [value] }, resources: { is: [level] } }, row => { seen.push(row[value]); }); + const spawn = System.create({ name: "conditionsTest/spawn" }, ({ commands }) => { commands.spawn([value])({ [value]: 7 }); }); + await world.run(spawn); + await world.run(watch); + expect(seen).toEqual([]); + await world.run(System.create({ name: "conditionsTest/ready", resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 1 }; })); + await world.run(watch); + expect(seen).toEqual([7]); + }); + + it("leave a reduced resource as it was when every contributor is skipped", async () => { + await using world = createWorld({ [workers]: 0 }); + let next = 5; + const add = System.create({ name: "conditionsTest/add", reduce: { [total]: "+" }, resources: { not: [paused] } }, ({ reduce }) => { reduce[total](next++); }); + await world.run(add); + expect(await readResource(world, total)).toBe(5); + await world.run(pause); + await world.run(add); + expect(await readResource(world, total)).toBe(5); + }); + + it("reject conditions that are never null or false, or listed twice, for untyped declarations, when declared", () => { + // @ts-expect-error a number is never null or false + expect(() => System.create({ name: "conditionsTest/number", resources: { is: [count] } }, () => {})).toThrow("which is never null or false"); + // @ts-expect-error both is and not + expect(() => System.create({ name: "conditionsTest/both", resources: { is: [paused], not: [paused] } }, () => {})).toThrow("in both resources is and not"); + // @ts-expect-error is already makes it readable + expect(() => System.create({ name: "conditionsTest/read", resources: { is: [level], read: [level] } }, ({ resources }) => { void resources[level]; })).toThrow("is already makes it readable"); + }); + it("skip a kernel the same way", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create({ name: "conditionsTest/seed" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); + await world.run(grow); + await world.run(pause); + await world.run(grow); + const [entity] = await select(world, { required: [value] }); + expect(await read(world, entity, value)).toBe(2); + }); + it("run a kernel only while its `is` condition holds", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create({ name: "conditionsTest/seed" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); + await world.run(growWhilePaused); + await world.run(pause); + await world.run(growWhilePaused); + const [entity] = await select(world, { required: [value] }); + expect(await read(world, entity, value)).toBe(2); + }); +}); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts new file mode 100644 index 000000000..ecca9e0f2 --- /dev/null +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vitest"; +import { identify } from "../../interface/registry/ids.test-helper.js"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { System } from "../../interface/system/system.js"; +import { count, read, readEach, readEntity, readResource, select } from "./reads.test-helper.js"; +import { World } from "../../interface/world/world.js"; +import { attachKernel } from "./attach-kernel.js"; +import { createWorld } from "../index.js"; +import { fillReports, reportOf, runReported } from "./reports.test-helper.js"; +import { Report } from "../../interface/report/report.js"; + +type Vec2 = readonly [number, number]; + +const position = Symbol("createTest/position"); +const velocity = Symbol("createTest/velocity"); +const label = Symbol("createTest/label"); +const frozen = Symbol("createTest/frozen"); +const spawnCount = Symbol("createTest/spawnCount"); +const seen = Symbol("createTest/seen"); +const marker = Symbol("createTest/marker"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number] }; + [velocity]: { type: number }; + [label]: { type: string }; + [frozen]: { type: true }; + [marker]: { type: number }; + } + export interface Resources { + [spawnCount]: { type: number }; + [seen]: { type: number }; + } +} + +Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(velocity, { type: { type: "number" } }); +Component.register(label, { type: { type: "string" } }); +Component.register(frozen, { type: { const: true } }); +Component.register(marker, { type: { type: "number" } }); +Resource.register(spawnCount, { type: { type: "integer", default: 3 } }); +Resource.register(seen, { type: { type: "integer", default: 0 } }); + +const spawn = System.create({ + name: "createTest/spawn", + resources: { read: [spawnCount], changed: [spawnCount] }, +}, ({ resources, commands }) => { + for (let i = 0; i < resources[spawnCount]; i++) commands.spawn([position, velocity, label])({ [position]: [i, 0], [velocity]: 1, [label]: `e${i}` }); +}); + +const move = System.create({ + name: "createTest/move", + components: { read: [velocity], write: [position], not: [frozen] }, + after: [spawn], +}, row => { + const [x, y] = row[position]; + row[position] = [x, y + row[velocity]]; +}); + +/** Counts rows whose position may have changed since it last ran. */ +const watch = System.create({ + name: "createTest/watch", + components: { changed: [position] }, + resources: { write: [seen] }, + after: [move], +}, (_, { resources }) => { + resources[seen] += 1; +}); + +/** A kernel whose module can't be imported. */ +const missing = attachKernel( + System.create({ name: "createTest/missing", kernel: "cpu", components: { has: [velocity] } }, () => {}), + { columns: [], shapes: [], resources: [], lookups: [], keys: [], spawns: [], structuredValues: [], url: "/@missing/kernel.js", program: null, reductions: [], reducedShapes: [] }, +); + +const main = System.create({ name: "createTest/main", systems: [spawn, move, watch] }); + +const positions = async (world: World): Promise => + (await readEach(world, await select(world, { required: [position], order: { [position]: true } }), position)).map(value => value ?? [NaN, NaN]); + +describe("World.create", () => { + it("spawns once from a changed resource, then integrates", async () => { + const world = createWorld(); + await world.run(main); + expect(await positions(world)).toEqual([[0, 0], [1, 0], [2, 0]]); + await world.run(main); + expect(await positions(world)).toEqual([[0, 1], [1, 1], [2, 1]]); + expect(await readResource(world, spawnCount)).toBe(3); + }); + + it("reports each change to a system exactly once", async () => { + const world = createWorld(); + await world.run(main); + // Spawned at the sync point, after watch ran: seen on the next run. + expect(await readResource(world, seen)).toBe(0); + await world.run(main); + expect(await readResource(world, seen)).toBe(3); + // Moved before watch in the last run, so already reported. + await world.run(watch); + expect(await readResource(world, seen)).toBe(3); + await world.run(move); + await world.run(watch); + expect(await readResource(world, seen)).toBe(6); + }); + + it("applies the net structural change of a sync point", async () => { + let first = Entity.none; + let second = Entity.none; + const edit = System.create({ name: "createTest/edit/net", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + if (input.action === "spawn") { + first = commands.spawn([velocity])({ [velocity]: 1 }); + second = commands.spawn([velocity])({ [velocity]: 2 }); + } + if (input.action === "toggle") { + commands.add(first, frozen, true); + commands.remove(first, frozen); + commands.add(second, [velocity, label], { [velocity]: 5, [label]: "second" }); + commands.despawn(first); + commands.add(first, label, "dead"); + } + }); + const world = createWorld(); + await world.run(edit, { action: "spawn" }); + expect(await count(world, { required: [velocity] })).toBe(2); + await world.run(edit, { action: "toggle" }); + expect(await readEntity(world, first, [velocity])).toBeNull(); + expect(await readEntity(world, second, [velocity, label])).toEqual({ [velocity]: 5, [label]: "second" }); + expect(await select(world, { required: [velocity] })).toEqual([second]); + }); + + it("reuses a despawned index only after a sync point, with a new generation", async () => { + const spawned: Entity[] = []; + const edit = System.create({ name: "createTest/edit/reuse", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + if (input.action === "spawn") spawned.push(commands.spawn([velocity])({ [velocity]: spawned.length })); + else for (const entity of spawned) commands.despawn(entity); + }); + const world = createWorld(); + // Past the runs that spawn reports, whose entities would take the freed index first. + await fillReports(world); + await world.run(edit, { action: "spawn" }); + await world.run(edit, { action: "despawn" }); + await world.run(edit, { action: "spawn" }); + const [old, reused] = spawned; + expect(reused >>> 0).toBe(old >>> 0); + expect(reused).not.toBe(old); + expect(await read(world, old, velocity)).toBeNull(); + expect(await read(world, reused, velocity)).toBe(1); + }); + + it("keeps rows consistent across swap-removes and archetype growth", async () => { + const spawned: Entity[] = []; + const edit = System.create({ name: "createTest/edit/rows", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + if (input.action === "spawn") for (let i = 0; i < 100; i++) spawned.push(commands.spawn([velocity, label])({ [velocity]: i, [label]: `${i}` })); + else for (let i = 0; i < 100; i += 3) commands.remove(spawned[i], label); + }); + const world = createWorld(); + await world.run(edit, { action: "spawn" }); + await world.run(edit, { action: "remove" }); + for (const [i, entity] of spawned.entries()) { + expect(await read(world, entity, velocity)).toBe(i); + expect(await read(world, entity, label)).toBe(i % 3 === 0 ? null : `${i}`); + } + expect(await count(world, { required: [label] })).toBe(66); + }); + + it("reads a component the world had no id for at an earlier read, once it has one", async () => { + await using world = createWorld(); + const spawnPlain = System.create({ name: "createTest/spawnPlain" }, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); }); + await world.run(spawnPlain); + const [entity] = await select(world, { required: [velocity] }); + expect(await read(world, entity, frozen)).toBe(null); + const freeze = System.create({ name: "createTest/freeze", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { + commands.add(input.entity, frozen, true); + }); + await world.run(freeze, { entity }); + expect(await read(world, entity, frozen)).toBe(true); + }); + + it("hides the resource entity from queries and commands", async () => { + const edit = System.create({ name: "createTest/edit/hidden", components: {} }, (row, { commands }) => { + commands.despawn(row.entity); + }); + const world = createWorld(); + expect(await count(world, { required: [] })).toBe(0); + await world.run(edit); + expect(await readResource(world, seen)).toBe(0); + }); + + it("throws at creating a composite with an unordered conflict, naming nothing", () => { + const push = System.create({ name: "createTest/push", components: { write: [position] } }, row => { row[position] = [0, 0]; }); + expect(() => System.create({ name: "createTest/conflicted", systems: [move, push] })).toThrow('"createTest/move" and "createTest/push"'); + expect(() => System.create({ name: "createTest/conflicted", systems: [move] })).not.toThrow(); + }); + + it("gives a body's context only the kinds its declaration declares", async () => { + let keys: string[] = []; + const probe = System.create({ name: "createTest/keys", resources: { read: [seen] } }, context => { + keys = Object.keys(context); + void context.resources[seen]; + }); + const world = createWorld(); + await world.run(probe); + expect(keys).toEqual(["resources", "commands"]); + }); + + it("rejects a key listed in both read and write, since write already makes it readable", () => { + // @ts-expect-error write already makes it readable + expect(() => System.create({ name: "createTest/both", components: { read: [velocity], write: [velocity] } }, row => { row[velocity] += 1; })) + .toThrow(`lists "createTest/velocity" in both components read and write`); + }); + + it("reports a system that throws, still applying its commands", async () => { + const fail = System.create({ name: "createTest/fail" }, ({ commands }) => { + commands.spawn([velocity])({ [velocity]: 1 }); + throw new Error("boom"); + }); + const world = createWorld(); + const [failure] = (await runReported(world, fail)).failures; + expect([failure.kind, failure.name, failure.message]).toEqual(["system", "createTest/fail", "boom"]); + expect(failure.stack).toContain("boom"); + expect(await count(world, { required: [velocity] })).toBe(1); + }); + + it("gives every run a report, with no errors for runs with nothing to report", async () => { + const quiet = System.create({ name: "createTest/quiet" }, () => {}); + await using world = createWorld(); + const first = await world.run(quiet); + expect(await world.run(quiet)).toBe(first + 1); + expect(await reportOf(world, first)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + expect(await count(world, { required: [Report.errors] })).toBe(0); + expect(await count(world, { required: [Report.run] })).toBeGreaterThanOrEqual(2); + }); + + it("reports what isn't an Error as text, without a stack", async () => { + const fail = System.create({ name: "createTest/failText" }, () => { + // A value that isn't an Error, which user code may throw. + throw { code: 7 }; + }); + await using world = createWorld(); + expect((await runReported(world, fail)).failures).toEqual([{ kind: "system", name: "createTest/failText", message: "[object Object]", stack: null }]); + }); + + it("reports the systems a failed tier skips, and the commands that did nothing", async () => { + const doomed = System.create({ name: "createTest/doomed" }, ({ commands }) => { + const entity = commands.spawn([velocity])({ [velocity]: 1 }); + commands.despawn(entity); + commands.add(entity, frozen, true); + commands.despawn(entity); + }); + const fails = System.create({ name: "createTest/failsFirst" }, () => { throw new Error("first"); }); + const later = System.create({ name: "createTest/later", after: [fails] }, () => {}); + await using world = createWorld(); + expect(await runReported(world, doomed)).toEqual({ failures: [], skipped: [], dropped: 2, warnings: [] }); + const report = await runReported(world, System.create({ name: "createTest/failedTier", systems: [fails, later] })); + expect(report.skipped).toEqual(["createTest/later"]); + expect(report.failures.map(failure => failure.name)).toEqual(["createTest/failsFirst"]); + }); + + it("skips every row when a system's only filter is an unchanged resource", async () => { + const count = System.create({ + name: "createTest/count", + components: { has: [velocity] }, + resources: { write: [seen], changed: [spawnCount] }, + }, (_, { resources }) => { + resources[seen] += 1; + }); + const world = createWorld(); + await world.run(spawn); + await world.run(count); + expect(await readResource(world, seen)).toBe(3); + await world.run(count); + expect(await readResource(world, seen)).toBe(3); + }); + + it("doesn't let a lookup match the resource entity", async () => { + let found: object | null | undefined; + const probe = System.create({ name: "createTest/probe", lookup: { any: {} } }, ({ lookup: { any } }) => { + // The resource entity is entity 1, since index 0 is never an entity and registration reserves the next first. + // @ts-expect-error an untyped caller can pass any number + found = any(1); + }); + const world = createWorld(); + await world.run(probe); + expect(found).toBeNull(); + }); + + it("undoes a registration whose kernels fail to load, so the next run tries again", async () => { + const world = createWorld(); + await expect(world.run(missing)).rejects.toThrow(); + await expect(world.run(missing)).rejects.toThrow(); + await world.run(spawn); + }); + + it("sees a change moved into an older chunk by a swap-remove", async () => { + const spawned: Entity[] = []; + const edit = System.create({ name: "createTest/edit/swap", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + if (input.action === "spawn") for (let i = 0; i < 2100; i++) spawned.push(commands.spawn([velocity])({ [velocity]: i })); + else commands.despawn(spawned[5]); + }); + const writer = System.create({ name: "createTest/writer", components: { write: [velocity] } }, row => { + if (row[velocity] === 2099) row[velocity] = -1; + }); + const changes: number[] = []; + const observer = System.create({ name: "createTest/observer", components: { read: [velocity], changed: [velocity] } }, row => { + changes.push(row[velocity]); + }); + const world = createWorld(); + await world.run(edit, { action: "spawn" }); + await world.run(observer); + changes.length = 0; + // The last row (in the third chunk) changes; then a despawn moves it into row 5, in the first chunk. + await world.run(writer); + await world.run(edit, { action: "despawn" }); + await world.run(observer); + expect(changes).toEqual([-1]); + }); + + it("selects with where and order", async () => { + const edit = System.create({ name: "createTest/edit/select" }, ({ commands }) => { + for (let i = 0; i < 10; i++) commands.spawn([position, velocity])({ [position]: [i % 3, i], [velocity]: i }); + }); + const world = createWorld(); + await world.run(edit); + const velocities = async (entities: Promise): Promise<(number | null)[]> => readEach(world, await entities, velocity); + expect(await velocities(select(world, { required: [velocity], where: { [velocity]: { "<": 3 } } }))).toEqual([0, 1, 2]); + expect(await velocities(select(world, { required: [velocity], where: { [velocity]: { ">=": 2, "!=": 4, "<": 6 } } }))).toEqual([2, 3, 5]); + expect(await velocities(select(world, { required: [position, velocity], where: { [position]: [1, 4] } }))).toEqual([4]); + expect(await velocities(select(world, { required: [velocity], order: { [velocity]: false } }))).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]); + expect(await velocities(select(world, { required: [position, velocity], where: { [velocity]: { ">": 1 } }, order: { [position]: true } }))) + .toEqual([3, 6, 9, 4, 7, 2, 5, 8]); + expect(await count(world, { required: [velocity], where: { [velocity]: { "<": 3 } } })).toBe(3); + }); + + it("spawns components named by symbols", async () => { + let spawned = Entity.none; + const edit = System.create({ name: "createTest/edit/symbols" }, ({ commands }) => { + spawned = commands.spawn([velocity, marker])({ [velocity]: 2, [marker]: 7 }); + }); + const world = createWorld(); + await world.run(edit); + expect(await readEntity(world, spawned, [velocity, marker])).toEqual({ [velocity]: 2, [marker]: 7 }); + }); + + it("starts resources at values given at creation, in place of their defaults", async () => { + const world = createWorld({ [spawnCount]: 2 }); + expect(await readResource(world, spawnCount)).toBe(2); + await world.run(spawn); + expect(await count(world, { required: [velocity] })).toBe(2); + expect(await readResource(createWorld(), spawnCount)).toBe(3); + // @ts-expect-error resources takes only resource names + expect(() => createWorld({ [velocity]: 1 })).toThrow(`"createTest/velocity" isn't a registered resource`); + // A string key is never a resource's, so it's ignored, as in commands. + const untyped: World = Reflect.apply(createWorld, undefined, [{ "createTest/spawnCount": 5, [spawnCount]: 2 }]); + expect(await readResource(untyped, spawnCount)).toBe(2); + }); + + it("has the gpuDevice resource, null without a device", async () => { + await using world = createWorld(); + expect(await readResource(world, gpuDevice)).toBeNull(); + let seen: unknown = undefined; + await world.run(System.create({ name: "createTest/readDevice", resources: { read: [gpuDevice] } }, ({ resources }) => { seen = resources[gpuDevice]; })); + expect(seen).toBeNull(); + }); + + it("accepts a system declared again the same under its id, as a module evaluated again declares it, and rejects a different one", async () => { + const first = System.create(identify({ name: "createTest/named", resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 1; }); + const again = System.create(identify({ name: "createTest/named", resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 2; }); + expect(() => System.create(identify({ name: "createTest/named" }, "createTest/systems.ts#named"), () => {})).toThrow('Cannot declare "createTest/named" (createTest/systems.ts#named): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); + await using world = createWorld(); + await world.run(first); + await world.run(again); + expect(await readResource(world, seen)).toBe(2); + }); + + it("refuses runs as soon as dispose is called", async () => { + const world = createWorld(); + void world.dispose(); + await expect(world.run(main)).rejects.toThrow("disposed"); + }); +}); diff --git a/packages/ecs/src/implementation/world/create.ts b/packages/ecs/src/implementation/world/create.ts new file mode 100644 index 000000000..0fd594a9f --- /dev/null +++ b/packages/ecs/src/implementation/world/create.ts @@ -0,0 +1,825 @@ +import type { Finalizer } from '../../interface/finalizer/created.js'; +import type { Rule } from '../../interface/rule/created.js'; +import { requireDefinition, type Definition } from '../../interface/registry/index.js'; +import type { World } from '../../interface/world/world.js'; +import type { Create, InitialResources } from '../../interface/world/create.js'; +import type { Join } from '../../interface/world/join.js'; +import type { Failure } from '../../interface/report/failure.js'; +import { joinWorld } from './join.js'; +import { createSharing, type ThreadProgress } from './sharing.js'; +import { gpuDevice } from '../../interface/resources/gpu-device.js'; +import { workers } from '../../interface/resources/workers.js'; +import { createGpu } from './gpu.js'; +import { readCell, writeCell } from './cells.js'; +import { finalizeAll, refreshFinalizerIndexes, type Thrown } from './finalizers.js'; +import { startWatches } from './indexes.js'; +import { clean as cleanReport, execute, type ExecuteContext } from './execute.js'; +import type { RunReport } from './failures.js'; +import { createReports, type RunValues, type Slot } from './reports.js'; +import { synchronizeAlone } from './synchronize.js'; +import { startRan } from './state.js'; +import type { CommandBuffer } from './buffer.js'; +import { keysOf } from './remote-systems.js'; +import { Report } from '../../interface/report/report.js'; +import { joinedIn, runObserver } from './observer.js'; +import { joinedRunOf, postObserver, settlePosts, settleObserverKeys } from './joined-observer.js'; +import type { Leaf } from '../../interface/system/leaf.js'; +import { settleKeys, type TailOutcome } from './tails.js'; +import { sharedMemoryAvailable } from './memory.js'; +import { createPool, type Pool } from './pool.js'; +import type { Runner } from './runner.js'; +import { platformWorkers, type WorkerPlatform, type WorkerPort } from './worker-platform.js'; +import { createPublisher } from './publish.js'; +import { flushBetween } from './layout-directory.js'; +import { refreshDirectories } from './directories.js'; +import { createRegistrar, type Registration } from './register.js'; +import type { Schedule } from '../../interface/registry/schedule.js'; +import { ensureResources, findResource, initialOf, setResource } from './resources.js'; +import { ensureProgramResources, activate, finalizerCrossing, placeFinalizers } from './activate.js'; +import { firstKeyOf, isObserver, isWorldWritten, kindOf, orderKeysOf, programVersion, scheduleOf, type Composite } from '../../interface/registry/index.js'; +import { always } from '../../interface/resources/always.js'; +import { cadence, type Cadence } from '../../interface/resources/cadence.js'; +import { clock } from '../../interface/resources/clock.js'; +import { wake } from '../../interface/resources/wake.js'; +import { stampChanged } from './archetype.js'; +import { failureOf } from './failures.js'; +import { createLoop, platformFrame, platformTimer, type Clock, type LoopPlatform } from './loop.js'; +import { createState, defaultOf, idOf, reside, startTicking, type Key } from './state.js'; +import { indexOf } from './entities.js'; +import { isHeld } from './transfers.js'; +import type { ReadonlyValue as ReadonlyResourceValue } from '../../interface/resource/value.js'; +import { labelOf, labelOfItem } from '../../interface/registry/index.js'; + +type Queue = (task: () => T | Promise) => Promise; + +/** Runs tasks one at a time, in call order; a failed task doesn't block the next. */ +const createQueue = (): Queue => { + let tail: Promise = Promise.resolve(); + return task => { + const result = tail.then(task); + tail = result.then(() => undefined, () => undefined); + return result; + }; +}; + +/** + * For tests and benchmarks: the chunk size kernels split at, whether kernels may use WASM and SIMD, the loop's + * platform, such as `backToBack()` for ticks as fast as tasks run, and where pool workers come from, such as + * `lentWorkers(...)` for workers another thread lent, or null for none; the platform's by default. + */ +export type WorldOptions = { + readonly minimumChunk?: number; + readonly wasm?: boolean; + readonly simd?: boolean; + readonly loop?: Partial; + readonly pool?: WorkerPlatform | null; +}; + +/** + * How much a world holds, by table, for tests that what a system holds lives only as long as it: on the world's + * thread, and on each pool worker. + */ +export type Tables = { + readonly schedules: number; + readonly runners: number; + readonly kernels: number; + readonly observed: number; + readonly afterSync: number; + readonly directories: number; + /** Indexes it keeps, for some registered system or finalizer, and how many builds of them ran in phases, and across the pool. */ + readonly indexes: number; + readonly phasedBuilds: number; + readonly pooledBuilds: number; + readonly journalReaders: number; + /** Bytes the arena holds. */ + readonly arena: number; + /** By joined thread with claims: how many indexes there hold a system, or are held until it drops one. */ + readonly joined: readonly number[]; + /** GPU records and index blocks, 0 without a device. */ + readonly gpu: number; + readonly workers: readonly { readonly kernels: number }[]; +}; + +/** + * A world as the implementation gives it, for tests and benchmarks: also `threads`, each shared thread's progress in + * the order they were shared, and `tables`, how much it holds, which the interface leaves out. + */ +export type CreatedWorld = World & { readonly threads: (notify: (value: readonly ThreadProgress[]) => void) => () => void; readonly tables: () => Promise }; + +/** The loop's state, added to a world when its first tick runs. */ +const loopResources: readonly Key[] = [cadence, always, wake, clock]; + +/** + * Loads kernels, rejecting with the first failure only once every load has settled, so none finishes after the + * registration it belonged to rolled back. + */ +const loadAll = async (runners: readonly Runner[]): Promise => { + const settled = await Promise.allSettled(runners.map(runner => runner.load())); + for (const result of settled) if (result.status === "rejected") throw result.reason; +}; + +/** A pool size from the `workers` resource: the platform's for null, else a whole number, at least 0. */ +const workersOf = (platform: WorkerPlatform, value: unknown): number => + value === null ? platform.count : typeof value === "number" && value > 0 ? Math.floor(value) : 0; + +/** Rows below which a kernel's archetype runs on the main thread rather than paying for a round trip. */ +const defaultMinimumChunk = 16384; + +/** A report with more failures, or the same report without any. */ +const withFailures = (report: RunReport, failures: readonly Failure[] | null): RunReport => + failures === null ? report : { ...report, failures: [...report.failures, ...failures] }; + +/** A report with its tails' failures and skipped systems. */ +const withTails = (report: RunReport, { failures, skipped }: TailOutcome): RunReport => failures === null && skipped === null ? report : { + ...report, failures: failures === null ? report.failures : [...report.failures, ...failures], skipped: skipped === null ? report.skipped : [...report.skipped, ...skipped], +}; + +/** Report components, by their first keys, for knowing which systems read reports. */ +const reportKeys: ReadonlySet = new Set(Report.Archetype.map(firstKeyOf)); + +/** Whether leaves read reports: name a report component in their declarations, lookups or order. */ +const readReports = (leaves: readonly Leaf[]): boolean => + leaves.some(leaf => [...keysOf(leaf), ...orderKeysOf(leaf.declaration)].some(key => reportKeys.has(firstKeyOf(key)))); + +/** Creates a world on this thread, as `World.create` does, with options for tests and benchmarks. */ +export const createWorld = (resources: InitialResources = {}, options: WorldOptions = {}): CreatedWorld => { + const shared = sharedMemoryAvailable(); + const initial = new Map(); + // String keys are never a resource's, so they're ignored, as commands ignore them. + for (const key of Object.getOwnPropertySymbols(resources)) { + if (kindOf(key) !== "resource") throw new Error(`"${labelOf(key)}" isn't a registered resource.`); + if (isWorldWritten(key)) throw new Error(`"${labelOf(key)}" is written only by the world.`); + initial.set(key, Reflect.get(resources, key)); + } + const device = resources[gpuDevice] ?? null; + const state = createState(shared, options.wasm ?? true, options.simd ?? true, device !== null, initial); + if (device !== null) state.gpu = createGpu(device); + const registrar = createRegistrar(state); + const enqueue = createQueue(); + /** Where pool workers come from; none without shared memory. */ + const platform = shared ? options.pool === undefined ? platformWorkers() : options.pool : null; + let workerCount = platform === null ? 0 : workersOf(platform, initialOf(state, workers)); + state.workers = workerCount; + /** Workers start with the first kernel, so worlds without kernels never spawn any. */ + let pool: Pool | null = null; + const context: { -readonly [K in keyof ExecuteContext]: ExecuteContext[K] } = { + state, runnerOf: registrar.runnerOf, kernelIndexOf: registrar.kernelIndexOf, workers: null, publish: () => {}, tails: null, + beforeSync: (failures, skipped, warnings, warningCount) => reports.beforeSync(failures, skipped, warnings, warningCount), + }; + + /** Waits for every joined thread's task in flight, as a tail may be, before what moves rows a tail may still read. */ + const drainTails = async (): Promise => { + while (state.channels.some(channel => channel.busy())) await Promise.all(state.channels.map(channel => channel.idle())); + // Observer systems on joined threads: those in progress finish, the rest are cancelled, owed runs posted again + // after. + if (state.observer.posts.length !== 0) await settlePosts(state, true); + }; + let disposed = false; + const ensured = { version: -1 }; + + /** Starts the pool with as many of the wanted workers as the platform has, or none: then kernels run here. */ + const startPool = (): Pool | null => { + const ports: WorkerPort[] = []; + while (platform !== null && ports.length < workerCount) { + const port = platform.start(); + if (port === null) break; + ports.push(port); + } + workerCount = state.workers = ports.length; + if (ports.length === 0) return null; + const started = createPool(ports, state.memory); + pool = started; + context.workers = { pool: started, minimumChunk: options.minimumChunk ?? defaultMinimumChunk, next: 0 }; + context.publish = createPublisher(state, started); + return started; + }; + + /** + * Whether a kernel loaded here since workers last caught up, or workers lost their loads: only then may one be + * behind, so runs of systems without kernels look at no kernel. + */ + let workersBehind = false; + + /** Loads kernels here, rejecting with the first failure only once every load has settled; workers are then behind. */ + const load = (runners: readonly Runner[]): Promise => { + if (runners.length === 0) return Promise.resolve(); + workersBehind = true; + return loadAll(runners); + }; + + /** Gives workers every kernel whose main-thread load is newer than theirs, including ones a failed registration reloaded. */ + const loadOnWorkers = async (): Promise => { + if (workerCount === 0 || !workersBehind) return; + const behind = registrar.kernels().filter(runner => runner.kernel?.staleOnWorkers() === true); + if (behind.length === 0) { + workersBehind = false; + return; + } + const workers = pool ?? startPool(); + if (workers === null) return; + context.publish(); + const setups = behind.flatMap(runner => runner.setup(registrar.kernelIndexOf(runner)) ?? []); + const results = await Promise.all(Array.from({ length: workers.size }, (_, worker) => workers.request(worker, { kind: "kernels", kernels: setups }))); + const failed = results.find(result => result.error !== undefined); + if (failed?.error !== undefined) throw failed.error; + for (const runner of behind) runner.kernel?.loadedOnWorkers(); + workersBehind = false; + }; + + /** + * Between runs, once tick arrays started: WASM lookups' directories, joined threads and workers see them before the + * next run's tasks. Joined threads read the layout directory with or without a pool. + */ + const publishLayouts = (): void => { + refreshDirectories(state); + flushBetween(state.directory, state.bumped, state.archetypes, state.entities, state.resourceEntity, state.counted); + context.publish(); + }; + + /** Adds resources, then loads kernels on the main thread and in every worker. Undoes the registration if loading fails. */ + const complete = async (registration: Registration): Promise => { + const { resources, ticked, resident, kernels } = registration; + try { + await drainTails(); + ensureResources(state, resources); + // Kept after a rollback: ticks nothing reads cost only memory. + startTicking(state, ticked); + const residing = reside(state, resident); + publishLayouts(); + await load(residing ? [...kernels, ...registrar.kernels().filter(runner => runner.kernel?.stale() === true && !kernels.includes(runner))] : kernels); + await loadOnWorkers(); + } + catch (error) { + registration.rollback(); + throw error; + } + }; + + /** Once indexes start kernels' stamps for more components: kernels that dropped them load again. */ + const reloadStamped = async (): Promise => { + publishLayouts(); + await load(registrar.kernels().filter(runner => runner.kernel?.stale() === true)); + await loadOnWorkers(); + }; + + const checkLive = (): void => { + if (disposed) throw new Error("The world is disposed."); + }; + + /** Pure: a resource no registered system uses still reads as its default. */ + const readResource = (key: Key): unknown => { + const cell = findResource(state, key); + return cell === undefined ? initialOf(state, key) : readCell(cell.storage, cell.row); + }; + + const deviceId = idOf(state, gpuDevice); + const deviceDefault = defaultOf(gpuDevice); + /** The `gpuDevice` resource's value, read each run, so without allocating. */ + const readDevice = (): ReadonlyResourceValue => { + const index = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + const storage = index < 0 ? undefined : state.archetypes[state.entities.archetype[index]]?.storageById[deviceId]; + const value = storage !== undefined ? readCell(storage, state.entities.row[index]) : state.initial.has(gpuDevice) ? state.initial.get(gpuDevice) : deviceDefault; + // The resource's value matches its registered schema, `GPUDevice | null`. + return value as ReadonlyResourceValue; + }; + + /** + * Before and after each run: the world's GPU storage follows its `gpuDevice` resource, which the world sets to null + * once the device is lost. A new device gets the storage, after what's ahead on the old one is read back unless it + * was lost; `kernel: "gpu"` systems move with it, and run on the CPU again without a device. + */ + /** Whether the `gpuDevice` resource names another device than the world runs on; a lost device is no device. */ + const deviceChanged = (): boolean => { + // Systems that need a device are then skipped, and systems watching it see the change. + if (state.gpu !== null && state.gpu.lost() !== null && readDevice() === state.gpu.device) setResource(state, gpuDevice, null); + return readDevice() !== (state.gpu?.device ?? null); + }; + + /** Moves the world onto the device `gpuDevice` names, or off the GPU. */ + const followDevice = async (): Promise => { + await drainTails(); + const device = readDevice(); + const current = state.gpu; + if (current !== null) { + // A lost device's copies are gone, so its readback keeps the CPU's. + await current.readback(); + current.destroy(); + } + state.gpu = device === null ? null : createGpu(device); + state.touched = device === null ? null : new Set(); + await load(registrar.placeGpuKernels()); + await loadOnWorkers(); + }; + + /** The `workers` resource's changed tick when the pool last followed it. */ + let workersSeen = -1; + const workersId = idOf(state, workers); + + /** Whether the `workers` resource changed since the pool last followed it; allocates nothing. */ + const workersChanged = (): boolean => { + if (state.resourceEntity < 0) return false; + const index = indexOf(state.resourceEntity); + const storage = state.archetypes[state.entities.archetype[index]]?.storageById[workersId]; + if (storage === undefined) return false; + const changed = storage.changed[state.entities.row[index]]; + if (changed === workersSeen) return false; + workersSeen = changed; + return true; + }; + + /** Sizes the kernel pool to the `workers` resource: a new size starts a new pool, with the next kernel. */ + const followWorkers = async (): Promise => { + const wanted = platform === null ? 0 : workersOf(platform, readResource(workers)); + if (wanted === workerCount) return; + workerCount = wanted; + state.workers = wanted; + const old = pool; + pool = null; + context.workers = null; + context.publish = () => {}; + for (const runner of registrar.kernels()) runner.kernel?.unloadedOnWorkers(); + workersBehind = true; + // The old pool releases its control state once its workers have left their windows; runs don't wait for it. + if (old !== null) void old.terminate(); + await loadOnWorkers(); + }; + + /** + * Whether a run of no tiers may skip its sync point: nothing is queued for it, and no GPU, joined thread or tail + * needs what a sync point does for them. + */ + const skipsSync = (): boolean => state.gpu === null && state.channels.length === 0 && state.tails.length === 0 && state.queued.length === 0 && state.observer.count === 0; + + /** Run numbers: every run and tick takes the next, in the order they run. */ + let runs = 0; + /** + * Commands of reports between runs, applied as a sync point of their own once observer systems on joined threads in + * progress have finished; or, while tails run there, which a sync point between runs would disturb, queued for the + * next run's or tick's. + */ + const applyAlone = (buffer: CommandBuffer): Promise | null => { + if (state.tails.length !== 0) { + state.queued.push({ buffer, guarded: false }); + return null; + } + const settling = state.observer.posts.length === 0 ? null : settlePosts(state, true); + if (settling === null) { + synchronizeAlone(state, buffer); + publishLayouts(); + return null; + } + return settling.then(() => { + if (disposed) return; + synchronizeAlone(state, buffer); + publishLayouts(); + }); + }; + const reports = createReports(state, applyAlone); + const reportIds = [idOf(state, Report.run)]; + const noIds: readonly number[] = []; + /** A tick's report, its slot and what it ran, which `perform` leaves without `pass`, with its tails in `tailing`. */ + let performed: RunReport = cleanReport; + let performedSlot: Slot | null = null; + let performedValues: RunValues | null = null; + + /** What the last run ran, for its report, reused run to run: a tick whose report waits for its tails copies it. */ + const ranValues: RunValues = { number: 0, elapsed: 0, systems: 0, workers: 0, joined: 0, main: false, gpu: false, dropped: 0 }; + /** What run `run` ran, gathered by its tiers, once its sync point is done, into `ranValues`. */ + const valuesOf = (run: number, started: number, dropped: number): RunValues => { + const { ran } = state; + ranValues.number = run; + ranValues.elapsed = performance.now() - started; + ranValues.systems = ran.systems; + ranValues.workers = context.workers?.pool.takePosted() ?? 0; + ranValues.joined = ran.joined.size; + ranValues.main = ran.main; + ranValues.gpu = ran.gpu; + ranValues.dropped = dropped; + return ranValues; + }; + + /** + * Writes a run's report into its slot after the run's sync point, its errors with it: once tails on joined threads + * reading reports have copied them, and observer systems there reading them have finished. + */ + const writeReport = (slot: Slot, values: RunValues, report: RunReport): Promise | null => { + if (disposed) return null; + const tails = state.tails.length === 0 ? null : settleKeys(state, reportIds, noIds); + const posts = state.observer.posts.length === 0 ? null : settleObserverKeys(state, reportIds, noIds); + if (tails === null && posts === null) return reports.finish(slot, values, report, state.tails.length !== 0, defer); + return Promise.all([tails, posts]).then(() => disposed ? undefined : reports.finish(slot, values, report, state.tails.length !== 0, defer) ?? undefined); + }; + + /** Puts failures into the next run's or tick's report: those found once a report's errors were written. */ + const defer = (failures: readonly Failure[]): void => { + (observerFailures ??= []).push(...failures); + }; + + /** + * Runs a registered system or composite, ending in a sync point, and resolves to its number. With `pass`, its + * report is written once its tails are done, then observer systems run, their failures added to it; without, its + * report is left in `performed` and its tails in `tailing`. One async step for all, so a run awaits nothing more. + * `registered` is its schedule as it was registered, which it runs if a system it holds was removed since, + * skipping that system. + */ + const perform = async (definition: Definition, value: unknown, pass: boolean, registered: Schedule | undefined): Promise => { + // Resources added between runs move the resource entity, whose structs a tail may still read. + if (ensured.version !== programVersion() && (state.channels.length > 0 || state.observer.posts.length > 0)) await drainTails(); + ensureProgramResources(state, ensured); + if (startWatches(state)) await reloadStamped(); + if (workersChanged()) await followWorkers(); + if (deviceChanged()) await followDevice(); + const schedule = registrar.registered(definition) ?? registered; + if (schedule === undefined) throw new Error(`System "${labelOfItem(definition)}" didn't register.`); + // An input that moved to a joined thread runs only there: that thread went since, its claims with it. + if (isHeld(value) && (definition.kind === "composite" || registrar.runnerOf(definition).remote?.channel !== value.channel)) { + throw new Error(`System "${labelOfItem(definition)}": the run's input moved to a joined thread that's gone.`); + } + const run = ++runs; + const started = performance.now(); + // A run of observer systems alone has an empty sync point. + const empty = schedule.tiers.length === 0 && skipsSync(); + // Its report's entity, spawned or reused by commands its own sync point applies, or for an empty one, applied + // alone, which leaves other commands for the next. + let slot: Slot | Promise = empty ? reports.beginAlone(run) : reports.begin(run); + if (slot instanceof Promise) slot = await slot; + let report: RunReport; + let tails: Promise | null = null; + try { + if (empty) { + startRan(state.ran); + report = cleanReport; + } + else report = await execute(context, schedule, value, definition); + } + finally { + tails = context.tails; + context.tails = null; + if (deviceChanged()) await followDevice(); + } + const values = valuesOf(run, started, report.dropped); + if (!pass) { + tailing = tails; + performed = report; + performedSlot = slot; + performedValues = values; + return run; + } + // A run is a transaction: it ends once its tails have. + if (tails !== null) report = withTails(report, await tails); + report = withFailures(report, takeFailures()); + const writing = writeReport(slot, values, report); + if (writing !== null) await writing; + const passed = passObserver(schedule.observer, loop.observer(), joinedRunOf(value)); + const failures = passed instanceof Promise ? await passed : passed; + if (failures !== null) defer(failures); + return run; + }; + + /** + * Failures for the next run's or tick's report: of registering observer systems `add` was given, and those joined + * threads report between runs. + */ + let observerFailures: Failure[] | null = null; + const takeFailures = (): Failure[] | null => { + const failures = observerFailures; + observerFailures = null; + return failures; + }; + const noLeaves: readonly Leaf[] = []; + const none: readonly never[] = []; + + /** + * Runs observer systems, the run's and those added, after a sync point, and posts those on joined threads, the + * run's owed to joined run `run` (0 for none); returns the failures of those that ran here. + */ + const passObserver = (own: readonly Leaf[], added: readonly Leaf[], run = 0): Failure[] | null | Promise => { + if (own.length === 0 && added.length === 0) return state.observer.pending.size === 0 ? null : postAfter(null, own, added, run); + const ran = runObserver(context, own, added, null); + if (state.observer.count === 0) return ran; + return ran instanceof Promise ? ran.then(done => postAfter(done, own, added, run)) : postAfter(ran, own, added, run); + }; + + /** Posts observer systems on joined threads after those here ran, then gives `failures`. */ + const postAfter = (failures: Failure[] | null, own: readonly Leaf[], added: readonly Leaf[], run: number): Failure[] | null | Promise => { + const posting = postObserver(state, joinedIn(context, own, false), joinedIn(context, added, true), run, context.workers?.pool ?? null); + return posting === null ? failures : posting.then(() => failures); + }; + // A post owed to a run, cancelled outside a sync point, is posted again between runs. + state.observer.again = () => void enqueue(async () => { + if (disposed) return; + const posting = postObserver(state, [], [], 0, context.workers?.pool ?? null); + if (posting !== null) await posting; + }); + + /** + * Registers an observer system `add` was given: at once when it can register again as it was + * (`Registrar.reregister`), otherwise before the next run, unless it was removed meanwhile; its failure is in the + * next pass's report. + */ + const registerObserver = (system: Leaf): void => { + if (registrar.registered(system) !== undefined || registrar.reregister(system)) return; + void enqueue(async () => { + if (disposed || registrar.registered(system) !== undefined || !loop.observer().includes(system)) return; + try { + await complete(registrar.register(system)); + } + catch (error) { + (observerFailures ??= []).push(failureOf("system", labelOfItem(system), error)); + } + }); + }; + + /** Whether observer systems read reports, so a tick waits for its tails before running them. */ + const observerReadReport = new WeakMap(); + const readsReportOf = (leaves: readonly Leaf[]): boolean => { + let reads = observerReadReport.get(leaves); + if (reads === undefined) observerReadReport.set(leaves, reads = readReports(leaves)); + return reads; + }; + + /** A tick's tails, which `perform` leaves for its report. */ + let tailing: Promise | null = null; + + /** Writes a resource only the world writes, stamped with a tick of its own so systems see it. */ + const writeOwn = (key: Key, value: unknown): void => { + const cell = findResource(state, key); + if (cell === undefined) return; + writeCell(cell.storage, cell.row, value); + stampChanged(cell.storage, cell.row, ++state.tick); + }; + + /** + * One tick: the clock, the added systems as one composite, then the tick's report, then observer systems with the + * tick's values, their failures in the next run's report. While its tails or an earlier tick's run, observer systems + * run at once, their failures in the tick's report written once those tails are done, unless one reads reports, when + * the tick waits for those tails first. + */ + const tick = (_: Composite, clockValue: Clock): Promise => enqueue(async () => { + // What's added as the tick starts: systems removed since the loop asked for it aren't in it. + const composite = loop.current(); + if (disposed || composite === null) return; + let report: RunReport; + let run: number; + let slot: Slot; + let values: RunValues; + const begun = runs; + try { + if (registrar.registered(composite) === undefined) { + ensureResources(state, loopResources); + await complete(registrar.register(composite)); + } + const schedule = registrar.registered(composite); + if (pending.length > 0 && readsReport(composite)) { + await Promise.all(pending.map(each => each.tails)); + const flushing = flushReports(); + if (flushing !== null) await flushing; + } + if (state.tails.length !== 0) { + const waiting = settleKeys(state, [], [clockId]); + if (waiting !== null) await waiting; + } + if (state.observer.posts.length !== 0) { + const waiting = settleObserverKeys(state, [], [clockId]); + if (waiting !== null) await waiting; + } + writeOwn(clock, clockValue); + run = await perform(composite, undefined, false, schedule); + report = performed; + performed = cleanReport; + slot = performedSlot ?? reports.lastSlot(); + values = performedValues ?? valuesOf(run, performance.now(), 0); + performedSlot = performedValues = null; + } + catch (error) { + // A tick that failed before its run took a number takes one now, its report spawned between runs. + if (runs === begun) { + run = ++runs; + slot = await reports.beginAlone(run); + } + else { + run = runs; + slot = reports.lastSlot(); + } + startRan(state.ran); + values = valuesOf(run, performance.now(), 0); + report = { failures: [failureOf("system", labelOfItem(composite), error)], skipped: none, dropped: 0, warnings: none }; + } + report = withFailures(report, takeFailures()); + const tails = tailing; + tailing = null; + const observer = loop.observer(); + if (tails !== null || pending.length > 0) { + if (observer.length === 0 || !readsReportOf(observer)) { + // Observer systems get the tick's values now; its report is written once its tails, and every earlier + // tick's, have run. + const passed = passObserver(noLeaves, observer); + const entry: PendingReport = { slot, values: { ...values }, report: withFailures(report, passed instanceof Promise ? await passed : passed), tails, done: tails === null }; + pending.push(entry); + if (tails !== null) void tails.then(outcome => { + entry.report = withTails(entry.report, outcome); + entry.done = true; + void enqueue(async () => flushReports()); + }); + const flushing = flushReports(); + if (flushing !== null) await flushing; + return; + } + await Promise.all(pending.map(each => each.tails)); + const flushing = flushReports(); + if (flushing !== null) await flushing; + if (tails !== null) report = withTails(report, await tails); + } + if (disposed) return; + const writing = writeReport(slot, values, report); + if (writing !== null) await writing; + const passed = passObserver(noLeaves, observer); + const failures = passed instanceof Promise ? await passed : passed; + if (failures !== null) defer(failures); + }); + + /** Ticks whose reports wait for their tails, in order. */ + type PendingReport = { readonly slot: Slot; readonly values: RunValues; report: RunReport; readonly tails: Promise | null; done: boolean }; + const pending: PendingReport[] = []; + /** + * Writes the reports of ticks whose tails, and every earlier tick's, have run: at once, unless one waits for + * observer systems in progress on joined threads, when the rest follow it. + */ + const flushReports = (): Promise | null => { + while (pending.length > 0 && pending[0].done) { + const entry = pending.shift(); + if (entry === undefined || disposed) continue; + const writing = writeReport(entry.slot, entry.values, entry.report); + if (writing !== null) return writing.then(() => flushReports() ?? undefined); + } + return null; + }; + const clockId = idOf(state, clock); + /** Whether a tick's systems read reports, so a tick waits for the last one's tails first. */ + const readersOfReport = new WeakMap(); + const readsReport = (composite: Composite): boolean => { + let reads = readersOfReport.get(composite); + if (reads === undefined) readersOfReport.set(composite, reads = registrar.registered(composite)?.tiers.some(readReports) ?? false); + return reads; + }; + + const loop = createLoop({ + settings: () => { + // Stored values match each resource's registered schema. + const cadenceValue = readResource(cadence) as Cadence; + return { cadence: cadenceValue, always: readResource(always) === true, wake: Number(readResource(wake)) }; + }, + tick, + now: options.loop?.now ?? (() => performance.now()), + frame: options.loop?.frame ?? platformFrame, + timer: options.loop?.timer ?? platformTimer, + }); + + /** Runs a system or composite with its input, as `run` does. */ + const runWith = (system: { readonly name: string }, value: unknown): Promise => { + let registration: Registration | null = null; + let definition: Definition | undefined; + try { + checkLive(); + definition = requireDefinition(system); + // A system registers in the world the first time it runs here. + if (registrar.registered(definition) === undefined) registration = registrar.register(definition); + } + catch (error) { + return Promise.reject(error); + } + const ran = definition; + const registering = registration; + const schedule = registrar.registered(ran); + // A run of observer systems alone doesn't wake the loop. + if (scheduleOf(ran).tiers.length > 0) loop.arrived(); + return enqueue(registering === null ? () => perform(ran, value, true, schedule) : async () => { + await complete(registering); + return perform(ran, value, true, schedule); + }); + }; + + const sharing = createSharing({ + state, + run: runWith, + defer: (failure: Failure) => { (observerFailures ??= []).push(failure); }, + crossing: (threads, imported) => registrar.crossing(threads, imported) ?? finalizerCrossing(threads, imported), + placed: () => { + registrar.place(); + placeFinalizers(state); + }, + between: task => void enqueue(async () => { + if (disposed) return; + await drainTails(); + task(); + }), + observer: { + add: system => { + if (!disposed) world.add(system); + }, + remove: system => world.remove(system), + drop: system => { + world.remove(system); + if (disposed) return; + // One never added, kept for its runs, is forgotten too. + const release = registrar.forget([system]); + void enqueue(async () => release()); + }, + }, + }); + + const world: CreatedWorld = { + run: (system, ...args) => { + const [value]: readonly unknown[] = args; + return runWith(system, value); + }, + share: port => { + checkLive(); + if (!state.memory.shared) throw new Error("Without cross-origin isolation memory can't be shared, so no thread can join."); + return sharing.share(port); + }, + add: system => { + checkLive(); + const definition = requireDefinition(system); + loop.add(definition); + if (isObserver(definition)) registerObserver(definition); + }, + remove: system => { + const definition = requireDefinition(system); + if (disposed) return; + const change = loop.remove(definition); + if (change === null) return; + let forgotten: readonly Definition[]; + if (isObserver(definition)) { + // An observer system added alone, which the composite ticks run doesn't hold: forgotten unless an added + // composite does. + if (loop.observer().includes(definition)) return; + forgotten = [definition]; + } + else { + // What's still added keeps its runners; the rest, the composite ticks ran included, is forgotten. + const kept = new Set(change.next === null ? [] : registrar.systemsOf(change.next)); + for (const leaf of loop.observer()) kept.add(leaf); + forgotten = change.previous === null ? [] : registrar.systemsOf(change.previous).filter(each => !kept.has(each)); + } + const release = registrar.forget(forgotten); + // What they held goes once the run or tick in progress, which may still use it, is done. + void enqueue(async () => release()); + }, + dispose: () => { + disposed = true; + loop.stop(); + return enqueue(async () => { + // Joined threads' runs reject from here on. + sharing.close(); + // Per-query finalizers release what every live entity they match holds, reading the GPU's writes first. + let errors: Thrown[] = []; + try { + // A finalizer added since the last run still releases what the world's entities hold. + ensureProgramResources(state, ensured); + activate(state); + if (state.finalizers.length > 0) { + if (state.gpu?.anyAhead() === true) await state.gpu.readback(); + refreshFinalizerIndexes(state); + errors = finalizeAll(state); + } + } + finally { + state.gpu?.destroy(); + await pool?.terminate(); + } + if (errors.length > 0) throw errors[0].error; + }); + }, + [Symbol.asyncDispose]: () => world.dispose(), + threads: sharing.progress, + tables: () => enqueue(async () => { + const current: Pool | null = pool; + const workers = current === null ? [] : await Promise.all(Array.from({ length: current.size }, (_, worker) => current.request(worker, { kind: "tables" }))); + return { + ...registrar.sizes(), + observed: state.observed.size, + afterSync: state.afterSync.size, + directories: state.directories.size, + indexes: state.indexes.size, + phasedBuilds: state.phasedBuilds, + pooledBuilds: state.pooledBuilds, + journalReaders: state.journal.readers, + arena: state.memory.held(), + joined: state.channels.map(channel => channel.placed()), + gpu: state.gpu?.records() ?? 0, + workers: workers.map(result => ({ kernels: result.kernels ?? -1 })), + }; + }), + }; + return world; +}; + +/** + * Creates a world, with resources starting at these values in place of their defaults. With `gpuDevice`, render + * systems and `kernel: "gpu"` kernels run on that device. + */ +export const create: Create = resources => createWorld(resources); + +/** Joins a world another thread shared, through the other end of its port, as `joinWorld` does. */ +export const join: Join = (port, options) => joinWorld(port, options); diff --git a/packages/ecs/src/implementation/world/directories.ts b/packages/ecs/src/implementation/world/directories.ts new file mode 100644 index 000000000..a1cb8fc92 --- /dev/null +++ b/packages/ecs/src/implementation/world/directories.ts @@ -0,0 +1,86 @@ +import { allocateArray, freeArray } from './arrays.js'; +import type { State } from './state.js'; + +/** + * A lookup's directory for WASM kernels: per archetype id, a match flag, then for a lookup through an index the address + * of the archetype's entities, then the column address of each component the lookup reads, then the `changed` tick + * column address of each it watches, 0 for one keeping no ticks. It lives in shared memory, so workers read the same + * one. Rebuilt at sync points when archetypes are created or grow, or start ticks, since all change those addresses. + */ +export type Directory = { + readonly entities: boolean; + readonly read: readonly number[]; + readonly watched: readonly number[]; + readonly has: readonly number[]; + readonly not: readonly number[]; + records: Int32Array; + /** Archetypes described. */ + count: number; + /** The layout it was built for: archetype count plus every archetype's version. */ + built: number; +}; + +const words = (directory: Directory): number => 1 + (directory.entities ? 1 : 0) + directory.read.length + directory.watched.length; + +const layoutOf = (state: State): number => { + let layout = state.archetypes.length; + for (const archetype of state.archetypes) layout += archetype.version; + return layout; +}; + +/** Rebuilds a directory if archetypes changed since it was built. Allocates, so it runs only at sync points or registration. */ +export const refreshDirectory = (state: State, directory: Directory, layout = layoutOf(state)): void => { + if (directory.built === layout) return; + const length = state.archetypes.length * words(directory); + if (directory.records.length < length) { + freeArray(state.memory, directory.records); + directory.records = allocateArray(state.memory, Int32Array, Math.max(length, directory.records.length * 2)); + } + const { records, read, has, not, watched } = directory; + state.archetypes.forEach((archetype, id) => { + const base = id * words(directory); + const { storageById } = archetype; + const matches = !archetype.resource && !(archetype.meta && read.length + has.length === 0) && [...read, ...has].every(component => storageById[component] !== undefined) && not.every(component => storageById[component] === undefined); + records[base] = matches ? 1 : 0; + const first = directory.entities ? 2 : 1; + if (directory.entities) records[base + 1] = matches ? archetype.entities.byteOffset : 0; + read.forEach((component, i) => { + const storage = storageById[component]; + records[base + first + i] = matches && storage !== undefined ? storage.views.u8.byteOffset : 0; + }); + watched.forEach((component, i) => { + const storage = storageById[component]; + records[base + first + read.length + i] = matches && storage !== undefined && storage.ticked ? storage.changed.byteOffset : 0; + }); + }); + directory.count = state.archetypes.length; + directory.built = layout; +}; + +export const createDirectory = (state: State, read: readonly number[], has: readonly number[], not: readonly number[], entities = false, watched: readonly number[] = []): Directory => { + const directory: Directory = { entities, read, watched, has, not, records: allocateArray(state.memory, Int32Array, 16), count: 0, built: -1 }; + state.directories.add(directory); + refreshDirectory(state, directory); + return directory; +}; + +/** Stops keeping a directory, freeing its records, once no kernel looks up through it again. */ +export const releaseDirectory = (state: State, directory: Directory): void => { + if (!state.directories.delete(directory)) return; + freeArray(state.memory, directory.records); +}; + +/** At a sync point: brings every directory up to date with the archetypes. */ +export const refreshDirectories = (state: State): void => { + if (state.directories.size === 0) return; + const layout = layoutOf(state); + for (const directory of state.directories) refreshDirectory(state, directory, layout); +}; + +/** What a WASM kernel with lookups takes, in its lookup parameters: the entity table, then each directory. */ +export const lookupParameters = (state: State, directories: readonly Directory[]): number[] => { + const { entities } = state; + const parameters = [entities.archetype.byteOffset, entities.row.byteOffset, entities.generation.byteOffset, entities.length]; + for (const directory of directories) parameters.push(directory.records.byteOffset, directory.count); + return parameters; +}; diff --git a/packages/ecs/src/implementation/world/directory.test.ts b/packages/ecs/src/implementation/world/directory.test.ts new file mode 100644 index 000000000..938b9eb0a --- /dev/null +++ b/packages/ecs/src/implementation/world/directory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { Resource } from "../../interface/resource/resource.js"; +import { indexOf, reserve } from "./entities.js"; +import { recordFlags, recordStorages, recordWords, resourceEntityWord, resourceFlag, storageColumn, storageComponent, storageWords, structureWord, tableOffsetWord } from "./layout-directory.js"; +import { ensureResources } from "./resources.js"; +import { createState, idOf } from "./state.js"; +import { applySync, prepareSync } from "./synchronize.js"; + +const gravity = Symbol("directoryTest/gravity"); +declare module "@adobe/ecs" { + export interface Resources { + [gravity]: { type: number }; + } +} +Resource.register(gravity, { type: { type: "number", default: -9.8 } }); + +describe("the layout directory", () => { + it("keeps the structure epoch odd from a sync point's start until its directory writes, and even otherwise, once a thread may read it", () => { + const state = createState(true); + const words = new Int32Array(state.memory.memory.buffer, 0, 16); + expect(words[structureWord]).toBe(0); + // No joined thread reads it, so a sync point leaves it. + reserve(state.entities); + expect(prepareSync(state)).toBe(true); + expect(words[structureWord]).toBe(0); + applySync(state); + expect(words[structureWord]).toBe(0); + if (state.directory !== null) state.directory.guarded = true; + // Nothing to apply leaves it. + expect(prepareSync(state)).toBe(false); + expect(words[structureWord]).toBe(0); + reserve(state.entities); + expect(prepareSync(state)).toBe(true); + expect(words[structureWord]).toBe(1); + applySync(state); + expect(words[structureWord]).toBe(2); + }); + it("records the resource archetype, flagged, and the resource entity, so other threads find resources' cells", () => { + const state = createState(true); + const words = new Uint32Array(state.memory.memory.buffer); + expect(words[resourceEntityWord]).toBe(0); + ensureResources(state, [gravity]); + const index = words[resourceEntityWord] - 1; + expect(index).toBe(indexOf(state.resourceEntity)); + const archetype = state.entities.archetype[index]; + const record = words[words[tableOffsetWord] / 4 + archetype] / 4; + expect(words[record + recordFlags]).toBe(resourceFlag); + const storages = Array.from({ length: words[record + recordStorages] }, (_, s) => record + recordWords + s * storageWords); + const storage = storages.find(base => words[base + storageComponent] === idOf(state, gravity)); + if (storage === undefined) throw new Error("No storage for gravity."); + const row = state.entities.row[index]; + expect(new Float64Array(state.memory.memory.buffer, words[storage + storageColumn], row + 1)[row]).toBe(-9.8); + }); +}); diff --git a/packages/ecs/src/implementation/world/dispatch.cases.ts b/packages/ecs/src/implementation/world/dispatch.cases.ts new file mode 100644 index 000000000..2311769bc --- /dev/null +++ b/packages/ecs/src/implementation/world/dispatch.cases.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./dispatch.fixture.js"; + +/** Tasks reach workers in order, through rings and messages: the cases, over the fixture built for node or from source. */ +export const dispatchCases = (load: () => Promise) => describe("worker dispatch", () => { + it("reloads kernels on workers when an observer registers", async () => { + const fixture = await load(); + expect(await fixture.reloadedOnObserver()).toBe(4096); + }, 200000); + for (const failing of [1, 2]) it(`keeps worker kernels in step after a failed registration (compile ${failing})`, async () => { + const fixture = await load(); + const { rejected, rows } = await fixture.afterFailedRegistration(failing); + expect(rejected).toContain("injected"); + expect(rows).toBe(4096); + }, 200000); + it("delivers layouts between back-to-back runs", async () => { + const fixture = await load(); + expect(await fixture.backToBack()).toEqual([0, 0, 0, 0, 0, 0]); + }, 200000); + for (const name of ["fastA,slowB", "fastA,fastC", "slowB,fastC"] as const) it(`runs tiers of ${name}`, async () => { + const fixture = await load(); + expect(await fixture.tier(name)).toBe("ok"); + }, 200000); + it("fast, message, fast kernels in one tier don't hang", async () => { + const fixture = await load(); + expect(await fixture.tier("fastA,slowB,fastC")).toBe("ok"); + }, 200000); + it("ring overflow followed by a message task in the same tier doesn't hang", async () => { + const fixture = await load(); + expect(await fixture.overflowThenMessage()).toBe("ok"); + }, 200000); +}); diff --git a/packages/ecs/src/implementation/world/dispatch.fixture.ts b/packages/ecs/src/implementation/world/dispatch.fixture.ts new file mode 100644 index 000000000..2c83ca31e --- /dev/null +++ b/packages/ecs/src/implementation/world/dispatch.fixture.ts @@ -0,0 +1,172 @@ +import { Component } from "../../interface/component/component.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { World } from "../../interface/world/world.js"; + +/** Scenarios of dispatching tasks to workers, run built in node and from source in chromium. */ + +type Vec2 = readonly [number, number]; +const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const satisfies Schema; +const position = Symbol("dispatchTest/position"); +const velocity = Symbol("dispatchTest/velocity"); +const offset = Symbol("dispatchTest/offset"); +const tagA = Symbol("dispatchTest/a"); +const tagB = Symbol("dispatchTest/b"); +const tagC = Symbol("dispatchTest/c"); +const tagD = Symbol("dispatchTest/d"); +const tagE = Symbol("dispatchTest/e"); +const tagF = Symbol("dispatchTest/f"); +const tagG = Symbol("dispatchTest/g"); +const tags = [tagA, tagB, tagC, tagD, tagE, tagF, tagG] as const; +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number] }; + [velocity]: { type: readonly [number, number] }; + [offset]: { type: number }; + [tagA]: { type: true }; [tagB]: { type: true }; [tagC]: { type: true }; [tagD]: { type: true }; + [tagE]: { type: true }; [tagF]: { type: true }; [tagG]: { type: true }; + } +} +Component.register(position, { type: vec2 }); +Component.register(velocity, { type: vec2 }); +Component.register(offset, { type: { type: "number", precision: 1 } }); +Component.register(tagA, { type: { const: true } }); +Component.register(tagB, { type: { const: true } }); +Component.register(tagC, { type: { const: true } }); +Component.register(tagD, { type: { const: true } }); +Component.register(tagE, { type: { const: true } }); +Component.register(tagF, { type: { const: true } }); +Component.register(tagG, { type: { const: true } }); + +const scatter = System.create({ name: "dispatchTest/scatter" }, ({ commands }) => { + for (let i = 0; i < 128 * 64; i++) { + const components: (typeof position | typeof velocity | typeof offset | (typeof tags)[number])[] = [position, velocity, offset]; + tags.forEach((tag, bit) => { if (((i >> 6) & (1 << bit)) !== 0) components.push(tag); }); + commands.spawn(components)({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); + } +}); +const seed = System.create({ name: "dispatchTest/seed" }, ({ commands }) => { + for (let i = 0; i < 4096; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); +}); +const fastA = System.create({ name: "dispatchTest/fastA", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { + const [x, y] = row[position]; + const [vx, vy] = row[velocity]; + row[position] = [x + vx, y + vy]; +}); +// Commands make it a JS kernel, dispatched by message. +const slowB = System.create({ name: "dispatchTest/slowB", kernel: "cpu", components: { read: [velocity] } }, (row, { commands }) => { + const [vx] = row[velocity]; + if (vx > 100) commands.despawn(row.entity); +}); +const fastC = System.create({ name: "dispatchTest/fastC", kernel: "cpu", components: { read: [velocity], write: [offset] } }, row => { + const [vx, vy] = row[velocity]; + row[offset] = vx + vy; +}); + +const withTimeout = (promise: Promise, ms: number): Promise => + Promise.race([promise, new Promise<"timeout">(resolve => setTimeout(() => resolve("timeout"), ms))]); + +const grow = System.create({ name: "dispatchTest/seed/2" }, ({ commands }) => { + for (let i = 0; i < 3000; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 1], [velocity]: [2, i % 3], [offset]: 0 }); +}); +let observedRows = 0; +const bump = System.create({ name: "dispatchTest/fastA/2", kernel: "cpu", components: { write: [offset] } }, row => { + row[offset] = row[offset] + 1; +}); +const watch = System.create({ name: "dispatchTest/slowB/2", components: { changed: [offset] } }, () => { observedRows++; }); +const later = System.create({ name: "dispatchTest/fastC/2", kernel: "cpu", components: { read: [velocity], write: [offset] }, after: [watch] }, row => { + const [vx, vy] = row[velocity]; + row[offset] = vx + vy; +}); +/** Runs `use` with a world, then disposes it. */ +const using = async (world: World, use: (world: World) => Promise): Promise => { + try { + return await use(world); + } + finally { + await world.dispose(); + } +}; + +/** Rows an observer registered after a kernel saw change once that kernel ran again. */ +export const reloadedOnObserver = (): Promise => using(createWorld({ [workers]: 2 }, { minimumChunk: 64 }), async world => { + await world.run(seed); + await world.run(bump); + await world.run(watch); + observedRows = 0; + await world.run(bump); + await world.run(watch); + return observedRows; +}); + +/** After a registration whose WASM failed to compile: what it rejected with, and the rows the observer then saw change. */ +export const afterFailedRegistration = (failing = 2): Promise<{ readonly rejected: string; readonly rows: number }> => using(createWorld({ [workers]: 2 }, { minimumChunk: 64 }), async world => { + await world.run(seed); + await world.run(bump); + const original = WebAssembly.compile; + let calls = 0; + WebAssembly.compile = (bytes: BufferSource) => (++calls === failing ? Promise.reject(new Error("injected")) : original(bytes)); + let rejected = "resolved"; + try { + await world.run(System.create({ name: "dispatchTest/tier/2", systems: [watch, later] })); + } + catch (error) { + rejected = String(error); + } + finally { + WebAssembly.compile = original; + } + await world.run(watch); + observedRows = 0; + await world.run(bump); + await world.run(watch); + return { rejected, rows: observedRows }; +}); + +/** Positions after 12 frames of growth then a kernel, back to back, or "timeout". */ +const backToBackRun = (count: number, wasm = true, delay = false): Promise => using(createWorld({ [workers]: count }, { minimumChunk: 64, wasm }), async world => { + for (let frame = 0; frame < 12; frame++) { + const r = await withTimeout((async () => { await world.run(grow); if (delay) await new Promise(r => setTimeout(r, 30)); await world.run(fastA); })(), 20000); + if (r === "timeout") return "timeout"; + } + return (await readEach(world, await select(world, { required: [position] }), position)).map(each => JSON.stringify(each)).sort(); +}); + +/** Positions that differ from the main thread alone's, or "timeout", for workers with JS and WASM kernels, with and without a delay. */ +export const backToBack = async (): Promise => { + const alone = await backToBackRun(0); + const diff = (a: readonly string[] | "timeout", b: readonly string[] | "timeout"): number | "timeout" => + a === "timeout" || b === "timeout" ? "timeout" : a.filter((v, i) => v !== b[i]).length; + const results = [diff(await backToBackRun(3, false), alone), diff(await backToBackRun(0, true), alone)]; + for (let i = 0; i < 2; i++) results.push(diff(await backToBackRun(3), alone)); + for (let i = 0; i < 2; i++) results.push(diff(await backToBackRun(3, true, true), alone)); + return results; +}; + +/** Tiers dispatched together: fast tasks through rings and message tasks, by the names of their systems. */ +export const tierSystems = { + "fastA,slowB": [fastA, slowB], + "fastA,fastC": [fastA, fastC], + "slowB,fastC": [slowB, fastC], + "fastA,slowB,fastC": [fastA, slowB, fastC], +} as const; + +let tiers = 0; + +/** A tier of `systems` run `frames` times on `count` workers after `prepare`: "ok", or "timeout" if a run hung. */ +const runTier = (systems: readonly System[], count: number, frames: number, prepare: System): Promise<"ok" | "timeout"> => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { + const tier = System.create({ name: `dispatchTest/tier/${3 + tiers++}`, systems: [...systems] }); + await world.run(prepare); + let result: unknown = "ok"; + for (let frame = 0; frame < frames && result !== "timeout"; frame++) result = await withTimeout(world.run(tier), 3000); + return result === "timeout" ? "timeout" : "ok"; +}); + +/** 50 frames of a tier on two workers. */ +export const tier = (name: keyof typeof tierSystems): Promise<"ok" | "timeout"> => runTier(tierSystems[name], 2, 50, seed); + +/** Five frames of a fast task and a message task in one tier, after one worker's ring overflowed. */ +export const overflowThenMessage = (): Promise<"ok" | "timeout"> => runTier([fastA, slowB], 1, 5, scatter); diff --git a/packages/ecs/src/implementation/world/entities.ts b/packages/ecs/src/implementation/world/entities.ts new file mode 100644 index 000000000..25de6371e --- /dev/null +++ b/packages/ecs/src/implementation/world/entities.ts @@ -0,0 +1,189 @@ +import type { Entity } from '../../interface/entity/entity.js'; +import { allocateArray, growArray } from './arrays.js'; +import type { Memory } from './memory.js'; + +/** An entity is `index + generation * 2^32`, so a stale reference to a reused index is detected. */ +const generationScale = 0x100000000; +/** Generations wrap at 2^21 so entities stay safe integers. */ +const generationMask = 0x1FFFFF; + +export const indexOf = (entity: number): number => entity >>> 0; +export const generationOf = (entity: number): number => Math.floor(entity / generationScale); + +/** + * The entity an id names. Ids passed here come only from the entity table, entity columns and command buffers, + * which hold nothing but entities. + */ +export const asEntity = (id: number): Entity => id as Entity; + +/** `cursor` slots. */ +const taken = 0; +const freeCount = 1; +export const tableLength = 2; + +/** What spawning needs, on the main thread or a worker. All of it lives in shared memory. */ +export type Reservations = { + /** `[reservations since the last sync point, free count, table length]`. */ + readonly cursor: Int32Array; + readonly free: Int32Array; + readonly generation: Uint32Array; +}; + +/** + * Where each entity index lives. `archetype` is -1 for an index that isn't alive. + * Spawning reserves through one atomic cursor: first indexes freed before the last sync point, from the end of `free`, + * then new indexes past `length`. + */ +export type Entities = Reservations & { + archetype: Int32Array; + row: Uint32Array; + generation: Uint32Array; + free: Int32Array; + length: number; + /** Indexes freed during the current sync point, reusable after it. */ + readonly released: number[]; + /** Incremented whenever `free`, `archetype`, `row` or `generation` is replaced, for the layout directory. */ + version: number; +}; + +const initialCapacity = 64; + +/** Index 0 is never alive and never freed, so `Entity.none` and zeroed entity values name no entity. */ +export const createEntities = (memory: Memory): Entities => { + const cursor = allocateArray(memory, Int32Array, 4).fill(0); + cursor[tableLength] = 1; + const archetype = allocateArray(memory, Int32Array, initialCapacity); + archetype[0] = -1; + return { + cursor, + free: allocateArray(memory, Int32Array, initialCapacity), + archetype, + row: allocateArray(memory, Uint32Array, initialCapacity), + generation: allocateArray(memory, Uint32Array, initialCapacity).fill(0), + length: 1, + released: [], + version: 0, + }; +}; + +/** Reserves an entity. Safe to call from any thread between sync points. */ +export const reserve = ({ cursor, free, generation }: Reservations): Entity => entityAt(cursor, free, generation, Atomics.add(cursor, taken, 1)); + +/** + * Reservations a thread takes in blocks of positions, so threads spawning together don't contend for the cursor. + * Positions a block leaves unused are reserved entities that never spawn, which the sync point releases. + */ +export type Block = { readonly table: Reservations; next: number; end: number; size: number }; + +const firstBlock = 8; +const largestBlock = 4096; + +export const createBlock = (table: Reservations): Block => ({ table, next: 0, end: 0, size: firstBlock }); + +/** Reserves an entity from the block, taking a block twice the last one's size, up to a limit, once it's used up. */ +export const reserveFrom = (block: Block): Entity => { + if (block.next === block.end) { + block.next = Atomics.add(block.table.cursor, taken, block.size); + block.end = block.next + block.size; + block.size = Math.min(block.size * 2, largestBlock); + } + const { cursor, free, generation } = block.table; + return entityAt(cursor, free, generation, block.next++); +}; + +/** + * Ends the block once its thread's task is done, appending the positions it left unused to `unused`, as start and end + * pairs, for the sync point to `unreserve`; the next block starts at the first size. + */ +export const endBlock = (block: Block, unused: number[]): void => { + if (block.next < block.end) unused.push(block.next, block.end); + block.next = block.end = 0; + block.size = firstBlock; +}; + +/** + * Returns positions `start` to `end` of the reservations a sync point materialized, which no spawn used, to the free + * list at its end. Their entities were never handed out, so their generations stay. Returns how many it returned. + */ +export const unreserve = (entities: Entities, { reused, firstNew }: Reserved, start: number, end: number): number => { + const { length } = reused; + for (let position = start; position < end; position++) entities.released.push(position < length ? reused[length - 1 - position] : firstNew + position - length); + return end - start; +}; + +/** The entity a reservation position names: first indexes freed before the last sync point, then new ones. */ +const entityAt = (cursor: Int32Array, free: Int32Array, generation: Uint32Array, position: number): Entity => { + const available = cursor[freeCount]; + if (position < available) { + const index = free[available - 1 - position]; + return asEntity(index + generation[index] * generationScale); + } + return asEntity(cursor[tableLength] + position - available); +}; + +/** Whether any entity was reserved since the last sync point. */ +export const reserved = (entities: Entities): boolean => Atomics.load(entities.cursor, taken) > 0; + +/** The index of a live entity, or -1. */ +export const locate = (entities: Pick, entity: number): number => { + const index = indexOf(entity); + return index < entities.length && entities.archetype[index] >= 0 && entities.generation[index] === generationOf(entity) ? index : -1; +}; + +const capacityFor = (current: number, needed: number): number => { + let capacity = current; + while (capacity < needed) capacity *= 2; + return capacity; +}; + +/** Indexes reserved since the last sync point: the reused ones, and new ones from `firstNew` up to the table's length. */ +export type Reserved = { readonly reused: Int32Array; readonly firstNew: number }; + +/** At the start of a sync point: makes reserved indexes addressable, not yet alive. */ +export const materialize = (memory: Memory, entities: Entities): Reserved => { + const { cursor } = entities; + const count = Atomics.exchange(cursor, taken, 0); + const reusedCount = Math.min(count, cursor[freeCount]); + // A typed copy, since the sync point recycles freed indexes over these. + const reused = entities.free.slice(cursor[freeCount] - reusedCount, cursor[freeCount]); + cursor[freeCount] -= reusedCount; + const firstNew = entities.length; + const length = entities.length + count - reusedCount; + if (length > entities.archetype.length) { + const capacity = capacityFor(entities.archetype.length, length); + entities.archetype = growArray(memory, entities.archetype, Int32Array, capacity, entities.length); + entities.row = growArray(memory, entities.row, Uint32Array, capacity, entities.length); + entities.generation = growArray(memory, entities.generation, Uint32Array, capacity, entities.length); + entities.version++; + } + entities.archetype.fill(-1, entities.length, length); + entities.generation.fill(0, entities.length, length); + entities.length = length; + cursor[tableLength] = length; + return { reused, firstNew }; +}; + +/** Kills an index, which isn't reusable until it's released: a joined thread's finalizers hold it. */ +export const kill = (entities: Entities, index: number): void => { + entities.archetype[index] = -1; + entities.generation[index] = (entities.generation[index] + 1) & generationMask; +}; + +/** Kills an index; it becomes reusable after the current sync point. */ +export const release = (entities: Entities, index: number): void => { + kill(entities, index); + entities.released.push(index); +}; + +/** At the end of a sync point: freed indexes become reservable. */ +export const recycle = (memory: Memory, entities: Entities): void => { + const { cursor, released } = entities; + const count = cursor[freeCount] + released.length; + if (count > entities.free.length) { + entities.free = growArray(memory, entities.free, Int32Array, capacityFor(entities.free.length, count), cursor[freeCount]); + entities.version++; + } + entities.free.set(released, cursor[freeCount]); + cursor[freeCount] = count; + released.length = 0; +}; diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.cases.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.cases.ts new file mode 100644 index 000000000..824decff5 --- /dev/null +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.cases.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./entity-lookup-workers.fixture.js"; + +/** Kernels' lookups by entity with `changed` across workers: the cases, over the fixture built for node or from source. */ +export const entityLookupWorkersCases = (load: () => Promise) => describe("kernels' lookups by entity with changed on workers", () => { + it.each([[true], [false]])("give what a main-thread system gives, WASM %s", async wasm => { + const fixture = await load(); + const alone = await fixture.simulate(0, wasm); + const one = await fixture.simulate(1, wasm); + const three = await fixture.simulate(3, wasm); + expect(alone.agree).toBe(true); + expect(one.agree).toBe(true); + expect(three.agree).toBe(true); + expect(one.rows).toEqual(alone.rows); + expect(three.rows).toEqual(alone.rows); + expect(three.tasks).toBeGreaterThan(0); + }); +}); diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts new file mode 100644 index 000000000..3dfa717ac --- /dev/null +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts @@ -0,0 +1,109 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { readEntity, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { countingWorkers } from "./platforms.test-helper.js"; + +/** Kernels' lookups by entity with `changed` across workers, beside a main-thread system doing the same: built in node and from source in chromium. */ + +const weight = Symbol("entityLookupWorkersTest/weight"); +const mark = Symbol("entityLookupWorkersTest/mark"); +const follows = Symbol("entityLookupWorkersTest/follows"); +const kernelSeen = Symbol("entityLookupWorkersTest/kernelSeen"); +const plainSeen = Symbol("entityLookupWorkersTest/plainSeen"); +const banned = Symbol("entityLookupWorkersTest/banned"); + +declare module "@adobe/ecs" { + interface Components { + [weight]: { type: number }; + [mark]: { type: number }; + [follows]: { type: Entity }; + [kernelSeen]: { type: number }; + [plainSeen]: { type: number }; + [banned]: { type: true }; + } +} +Component.register(weight, { type: { type: "number" } }); +Component.register(mark, { type: { type: "number" } }); +Component.register(follows, { type: Entity.schema }); +Component.register(kernelSeen, { type: { type: "number" } }); +Component.register(plainSeen, { type: { type: "number" } }); +Component.register(banned, { type: { const: true } }); + +/** + * Counts, in base 4 by run, what the lookups gave the entity a row follows: 0 not found, 1 unchanged, 2 changed, and + * whether its mark changed in the next digit, then whether it has a mark and isn't banned. + */ +const watchKernel = System.create({ + name: "entityLookupWorkersTest/watchKernel", kernel: "cpu", + components: { read: [follows], write: [kernelSeen] }, + lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, +}, (row, { lookup }) => { + const found = lookup.found(row[follows]); + const marked = lookup.marked(row[follows]); + const seen = found === null ? 0 : found[weight] >= 0 ? found.changed ? 2 : 1 : 3; + const flagged = marked === null ? 0 : marked[mark] > 0 ? marked.changed ? 2 : 1 : 3; + const eligible = lookup.eligible(row[follows]) === null ? 0 : 1; + row[kernelSeen] = ((row[kernelSeen] * 4 + seen) % 1000003 * 4 + flagged) * 2 + eligible; +}); + +const watchPlain = System.create({ + name: "entityLookupWorkersTest/watchPlain", + components: { read: [follows], write: [plainSeen] }, + lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, +}, (row, { lookup }) => { + const found = lookup.found(row[follows]); + const marked = lookup.marked(row[follows]); + const seen = found === null ? 0 : found[weight] >= 0 ? found.changed ? 2 : 1 : 3; + const flagged = marked === null ? 0 : marked[mark] > 0 ? marked.changed ? 2 : 1 : 3; + const eligible = lookup.eligible(row[follows]) === null ? 0 : 1; + row[plainSeen] = ((row[plainSeen] * 4 + seen) % 1000003 * 4 + flagged) * 2 + eligible; +}); + +const tick = System.create({ name: "entityLookupWorkersTest/tick", systems: [watchKernel, watchPlain] }); + +const targets: Entity[] = []; +const seed = System.create({ name: "entityLookupWorkersTest/seed" }, ({ commands }) => { + targets.length = 0; + for (let k = 0; k < 500; k++) targets.push(k % 3 === 0 ? commands.spawn([weight, mark])({ [weight]: k, [mark]: 1 }) : commands.spawn([weight])({ [weight]: k })); + for (let k = 0; k < 4000; k++) commands.spawn([follows, kernelSeen, plainSeen])({ [follows]: targets[(k * 7) % targets.length], [kernelSeen]: 0, [plainSeen]: 0 }); +}); + +/** Writes some weights, marks and unmarks some targets, despawns some and spawns others: a different change each round. */ +const stir = System.create({ name: "entityLookupWorkersTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [weight] } }, (row, { input, commands }) => { + const w = row[weight]; + if ((w + input.round) % 5 === 0) row[weight] = w + 1000; + if ((w + input.round) % 11 === 0) commands.add(row.entity, mark, 2); + if ((w + input.round) % 13 === 0) commands.remove(row.entity, mark); + if ((w + input.round) % 7 === 0) commands.add(row.entity, banned, true); + if ((w + input.round) % 17 === 0) commands.remove(row.entity, banned); + if ((w + input.round) % 97 === 0) commands.despawn(row.entity); +}); + +/** Every row's record of what both systems saw over 6 rounds, sorted, and the run tasks and windows posted to workers. */ +export const simulate = async (threads: number, wasm: boolean): Promise<{ readonly rows: readonly string[]; readonly agree: boolean; readonly tasks: number }> => { + const counting = countingWorkers(); + const ecs = createWorld({ [workers]: threads }, { minimumChunk: 64, wasm, pool: counting.platform }); + try { + await ecs.run(seed); + const posted = (): number => counting.posted("run") + counting.posted("window"); + const before = posted(); + for (let round = 0; round < 6; round++) { + await ecs.run(tick); + await ecs.run(tick); + await ecs.run(stir, { round }); + } + await ecs.run(tick); + const tasks = posted() - before; + const rows = await Promise.all((await select(ecs, { required: [follows] })).map(async entity => { + const values = await readEntity(ecs, entity, [kernelSeen, plainSeen]); + return values === null ? [] : [values[kernelSeen], values[plainSeen]]; + })); + return { rows: rows.map(row => JSON.stringify(row)).sort(), agree: rows.every(([kernel, plain]) => kernel === plain), tasks }; + } + finally { + await ecs.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts new file mode 100644 index 000000000..79cc15c0b --- /dev/null +++ b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { edit, probe, scene, threadProbe, value } from "./entity-lookups.test-helper.js"; + +/** Writes every `value` on the GPU, which stamps its whole column at once. */ +const bumpOnGpu = System.create({ name: "entityLookupsTest/bumpOnGpu", kernel: "gpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +/** Whether `probe` sees a GPU kernel's write to what its lookup watches once, run by run. */ +const gpuWrites = async (world: CreatedWorld, probe: System): Promise => { + await world.run(edit, { action: "spawn" }); + scene.seen = []; + await world.run(probe); + await world.run(probe); + await world.run(bumpOnGpu); + await world.run(probe); + await world.run(probe); + return scene.seen.map(seen => [seen.found, seen.value]); +}; + +const expected = [[true, 1], [false, 1], [true, 2], [false, 2]]; + +describe("lookups by entity with changed, after a GPU kernel's writes", () => { + it("see them on the world's thread", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + expect(await gpuWrites(world, probe)).toEqual(expected); + await world.dispose(); + }); + it("see them on a joined thread", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["entityLookupsTest"] }); + await new Promise(resolve => { + let done = false; + const stop = world.threads(threads => { + if (done || threads.at(-1)?.status !== "joined") return; + done = true; + queueMicrotask(() => stop()); + resolve(); + }); + }); + expect(await gpuWrites(world, threadProbe)).toEqual(expected); + await remote.dispose(); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts new file mode 100644 index 000000000..fe6ecdb0a --- /dev/null +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -0,0 +1,201 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { fillReports } from "./reports.test-helper.js"; + +// Lookups by entity with `changed`, where systems run: probes on the world's thread, and on thread +// "entityLookupsTest", which a joined thread fills, as they read a probe's object there. + +export const value = Symbol("entityLookupsTest/value"); +export const other = Symbol("entityLookupsTest/other"); +export const tag = Symbol("entityLookupsTest/tag"); +export const extra = Symbol("entityLookupsTest/extra"); +export const target = Symbol("entityLookupsTest/target"); +export const label = Symbol("entityLookupsTest/label"); +declare module "@adobe/ecs" { + export interface Threads { + entityLookupsTest: true; + } + export interface Components { + [value]: { type: number }; + [other]: { type: number }; + [tag]: { type: true }; + [extra]: { type: number }; + [target]: { type: Entity }; + [label]: { type: { readonly text: string } | null; thread: "entityLookupsTest" }; + } +} +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(other, { type: { type: "number" } }); +Component.register(tag, { type: { const: true } }); +Component.register(extra, { type: { type: "number" } }); +Component.register(target, { type: Entity.schema }); +Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: { text: "probe" } }, thread: "entityLookupsTest" }); + +/** + * What a probe saw of its target in one run: each lookup's `changed`, or null where it found nothing; the value it + * read; whether a lookup with `changed` gave one row for two calls; whether lookups reading nothing gave one shared + * row; and whether the entity a reused index held before was found. + */ +export type Seen = { + readonly found: boolean | null; + readonly value: number | null; + readonly tagged: boolean | null; + readonly reused: boolean; + readonly shared: boolean; + readonly stale: boolean; +}; + +/** The entity probes look up, the one its index held before, and every probe run's view, in order. */ +export const scene: { subject: Entity; stale: Entity; seen: Seen[] } = { subject: Entity.none, stale: Entity.none, seen: [] }; + +/** One probe run's view of `subject`. */ +const seenOf = (subject: Entity, lookup: { + readonly found: (entity: Entity) => { readonly [value]: number; readonly changed: boolean } | null; + readonly tagged: (entity: Entity) => { readonly changed: boolean } | null; + readonly empty: (entity: Entity) => object | null; + readonly has: (entity: Entity) => object | null; +}): Seen => { + const found = lookup.found(subject); + const changed = found?.changed ?? null; + const read = found?.[value] ?? null; + const reused = found !== null && lookup.found(subject) === found; + const tagged = lookup.tagged(subject)?.changed ?? null; + const empty = lookup.empty(subject); + const has = lookup.has(subject); + const stale = scene.stale !== Entity.none && lookup.found(scene.stale) !== null; + return { found: changed, value: read, tagged, reused, shared: empty !== null && (has === null || has === empty), stale }; +}; + +/** On the world's thread. */ +export const probe = System.create({ + name: "entityLookupsTest/probe", + components: { read: [target] }, + lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, +}, (row, { lookup }) => { + scene.seen.push(seenOf(row[target], lookup)); +}); + +/** The same, registered later, so its first run comes after every change. */ +export const lateProbe = System.create({ + name: "entityLookupsTest/lateProbe", + components: { read: [target] }, + lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, +}, (row, { lookup }) => { + scene.seen.push(seenOf(row[target], lookup)); +}); + +/** On thread "entityLookupsTest", as it reads the probe's label there. */ +export const threadProbe = System.create({ + name: "entityLookupsTest/threadProbe", + components: { read: [target, label] }, + lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, +}, (row, { lookup }) => { + if (row[label]?.text === "probe") scene.seen.push(seenOf(row[target], lookup)); +}); + +/** Writes every `value` in its rows, stamping them. */ +export const bump = System.create({ name: "entityLookupsTest/bump", components: { write: [value] } }, row => { + row[value] += 1; +}); + +/** The same as a kernel, whose stamps are muted unless something watches `value`. */ +export const bumpKernel = System.create({ name: "entityLookupsTest/bumpKernel", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); + +/** Writes every `other`, which no lookup watches. */ +export const touch = System.create({ name: "entityLookupsTest/touch", components: { write: [other] } }, row => { + row[other] += 1; +}); + +/** Changes the subject by command: `action` names how. */ +export const edit = System.create({ + name: "entityLookupsTest/edit", + input: { type: "object", properties: { action: { type: "string" } } }, +}, ({ input, commands }) => { + const { subject } = scene; + switch (input.action) { + case "spawn": + scene.subject = commands.spawn([value, other, tag])({ [value]: 1, [other]: 0, [tag]: true }); + commands.spawn([target, label])({ [target]: scene.subject }); + break; + case "set": commands.add(subject, value, 5); break; + case "move": commands.add(subject, extra, 1); break; + case "untag": commands.remove(subject, tag); break; + case "retag": commands.add(subject, tag); break; + case "despawn": commands.despawn(subject); break; + case "reuse": + scene.stale = subject; + scene.subject = commands.spawn([value, tag])({ [value]: 7, [tag]: true }); + break; + } +}); + +/** Points every probe at the subject. */ +export const retarget = System.create({ name: "entityLookupsTest/retarget", components: { write: [target] } }, row => { + row[target] = scene.subject; +}); + +/** + * Runs the edits, `probe` after each: what every probe run saw, and the subject's index and generation before and + * after its index was reused. + */ +export const scenario = async (world: World, probe: System): Promise<{ readonly seen: readonly Seen[]; readonly reused: boolean }> => { + scene.subject = scene.stale = Entity.none; + scene.seen = []; + // Past the runs that spawn reports, whose entities would take the subject's freed index first. + await fillReports(world); + await world.run(edit, { action: "spawn" }); + // The first run, then one with nothing changed. + await world.run(probe); + await world.run(probe); + // A write to the listed component, by a system and by command; one to another component. + await world.run(bump); + await world.run(probe); + await world.run(edit, { action: "set" }); + await world.run(probe); + await world.run(touch); + await world.run(probe); + // Moving to another archetype, then losing and gaining a listed component. + await world.run(edit, { action: "move" }); + await world.run(probe); + await world.run(edit, { action: "untag" }); + await world.run(probe); + await world.run(edit, { action: "retag" }); + await world.run(probe); + // Despawned, then its index reused by a new entity. + await world.run(edit, { action: "despawn" }); + await world.run(probe); + await world.run(edit, { action: "reuse" }); + await world.run(retarget); + await world.run(probe); + await world.run(probe); + const reused = (scene.stale >>> 0) === (scene.subject >>> 0) && scene.stale !== scene.subject; + return { seen: scene.seen, reused }; +}; + +/** What `scenario` sees, run by run. */ +export const expected: readonly Seen[] = [ + // First run: everything counts as changed. + { found: true, value: 1, tagged: true, reused: true, shared: true, stale: false }, + { found: false, value: 1, tagged: false, reused: true, shared: true, stale: false }, + // bump + { found: true, value: 2, tagged: false, reused: true, shared: true, stale: false }, + // set + { found: true, value: 5, tagged: false, reused: true, shared: true, stale: false }, + // touch + { found: false, value: 5, tagged: false, reused: true, shared: true, stale: false }, + // move: its values moved with it, unchanged + { found: false, value: 5, tagged: false, reused: true, shared: true, stale: false }, + // untag: the lookup that has it finds nothing + { found: false, value: 5, tagged: null, reused: true, shared: true, stale: false }, + // retag: added again, so changed + { found: false, value: 5, tagged: true, reused: true, shared: true, stale: false }, + // despawn + { found: null, value: null, tagged: null, reused: false, shared: false, stale: false }, + // reuse: a new entity in its index, the old one not found + { found: true, value: 7, tagged: true, reused: true, shared: true, stale: false }, + { found: false, value: 7, tagged: false, reused: true, shared: true, stale: false }, +]; diff --git a/packages/ecs/src/implementation/world/entity-lookups.test.ts b/packages/ecs/src/implementation/world/entity-lookups.test.ts new file mode 100644 index 000000000..36699bf41 --- /dev/null +++ b/packages/ecs/src/implementation/world/entity-lookups.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { workers } from "../../interface/resources/workers.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { System } from "../../interface/system/system.js"; +import { backToBack } from "./loop.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { bumpKernel, edit, expected, lateProbe, probe, scenario, scene, target, threadProbe, value } from "./entity-lookups.test-helper.js"; +import { runReported } from "./reports.test-helper.js"; + +/** What a finalizer's lookup gave as probes were despawned. */ +const finalized: (boolean | null)[] = []; +Finalizer.create({ name: "entityLookupsTest/finalize", components: { read: [target] }, lookup: { found: { read: [value], changed: [value] } } }, (row, { lookup }) => { + finalized.push(lookup.found(row[target])?.changed ?? null); +}); +const despawnProbes = System.create({ name: "entityLookupsTest/despawnProbes", components: { has: [target] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); + +const keepTicking = System.create({ name: "entityLookupsTest/keepTicking" }, () => {}); + +/** A world shared with a thread on this one, through a MessageChannel, which claims the probes' thread. */ +const joined = async () => { + const world: CreatedWorld = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["entityLookupsTest"] }); + await new Promise(resolve => { + let done = false; + const stop = world.threads(threads => { + if (done || threads.at(-1)?.status !== "joined") return; + done = true; + queueMicrotask(() => stop()); + resolve(); + }); + }); + return { world, remote }; +}; + +describe("lookups by entity with changed", () => { + it("report a listed component's writes since the system last ran, on the world's thread", async () => { + const world = createWorld({ [workers]: 0 }); + const { seen, reused } = await scenario(world, probe); + expect(reused).toBe(true); + expect(seen).toEqual(expected); + // A system registered now sees every row as changed on its first run, then nothing. + scene.seen = []; + await world.run(lateProbe); + await world.run(lateProbe); + expect(scene.seen).toEqual([expected.at(-2), expected.at(-1)]); + await world.dispose(); + }); + it("report the same on a joined thread", async () => { + const { world, remote } = await joined(); + const { seen, reused } = await scenario(world, threadProbe); + expect(reused).toBe(true); + expect(seen).toEqual(expected); + expect((await runReported(world, threadProbe)).warnings.map(warning => warning.name)).toEqual(["entityLookupsTest/threadProbe"]); + await remote.dispose(); + await world.dispose(); + }); + it("count every row as changed in finalizers", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(edit, { action: "spawn" }); + finalized.length = 0; + await world.run(despawnProbes); + expect(finalized).toEqual([true]); + await world.dispose(); + }); + for (const wasm of [false, true]) { + it(`see a ${wasm ? "WASM" : "JS"} kernel's writes to what they watch, which keeps its stamps`, async () => { + const world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(edit, { action: "spawn" }); + scene.seen = []; + await world.run(probe); + await world.run(probe); + await world.run(bumpKernel); + await world.run(probe); + await world.run(probe); + expect(scene.seen.map(seen => [seen.found, seen.value])).toEqual([[true, 1], [false, 1], [true, 2], [false, 2]]); + await world.dispose(); + }); + } + it("watch what they name while their system lives", async () => { + const driver = backToBack(); + await using world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + await world.run(edit, { action: "spawn" }); + /** Once `count` more ticks ran. */ + const ticks = async (count: number): Promise => { + const first = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await new Promise(resolve => { + const stop = Observe.resource(world, clock)(({ tick }) => { + if (tick < first + count) return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + }; + world.add(keepTicking); + await ticks(1); + const before = (await world.tables()).observed; + world.add(probe); + await ticks(2); + expect((await world.tables()).observed).toBe(before + 2); + world.remove(probe); + await ticks(2); + expect((await world.tables()).observed).toBe(before); + driver.close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/execute.ts b/packages/ecs/src/implementation/world/execute.ts new file mode 100644 index 000000000..29873be52 --- /dev/null +++ b/packages/ecs/src/implementation/world/execute.ts @@ -0,0 +1,609 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import type { Chunk, ChunkResult, Runner, Workers } from './runner.js'; +import type { Schedule } from '../../interface/registry/schedule.js'; +import type { Archetype, Storage } from './archetype.js'; +import { countRan, idOf, startRan, type State } from './state.js'; +import type { GpuEngine } from './gpu.js'; +import { applySync, prepareSync, syncPending } from './synchronize.js'; +import { activate } from './activate.js'; +import { markRun } from './move-log.js'; +import { postTails, settleAll, settleKeys, settleRunners, tailsOf, type TailOutcome } from './tails.js'; +import { settlePosts, settleObserverKeys, settleObserverRunners } from './joined-observer.js'; +import { locate } from './entities.js'; +import { clearBuffer, createBuffer } from './buffer.js'; +import { drainSegment, type Segment } from './command-ring.js'; +import { combine, identityOf, writeReduction, type ReductionTarget } from './reductions.js'; +import type { Failure, Warning } from '../../interface/report/failure.js'; +import type { RunReport } from './failures.js'; +import { liftColumnStamps, type RemoteSystem, type TaskChannel } from './remote-systems.js'; +import { failureOf } from './failures.js'; +import { checkCommands } from './remote.js'; +import { firstKeyOf, labelOfItem } from '../../interface/registry/index.js'; +import { mergeUses, refreshUses, type IndexUse } from './indexes.js'; + +/** Shared by every report without failures or skipped systems: they're read-only, so none needs its own. */ +const none: readonly never[] = []; +const noChunks: readonly Chunk[] = []; +/** The report of a run with nothing to report. */ +export const clean: RunReport = { failures: none, skipped: none, dropped: 0, warnings: none }; + +export type ExecuteContext = { + readonly state: State; + readonly runnerOf: (leaf: Leaf) => Runner; + readonly kernelIndexOf: (runner: Runner) => number; + readonly workers: Workers | null; + /** Tells workers about layout changes; called after the sync point. */ + readonly publish: () => void; + /** The last run's tails, posted after its sync point, until the caller takes them; null when it had none. */ + tails: Promise | null; + /** + * Given what went wrong before a run's sync point, each list null or a count of none, queues what its report needs + * for them into that sync point. + */ + readonly beforeSync: (failures: readonly Failure[] | null, skipped: readonly string[] | null, warnings: readonly Warning[], warningCount: number) => void; +}; + +/** + * Runs a schedule tier by tier, then applies one sync point, and reports what went wrong. Within a tier, kernels start + * first so workers run while main-thread systems do; commands merge in tier order, then chunk order, whichever thread + * ran them. A failing system stops the run after its tier finishes: later tiers are skipped, and commands so far still + * apply. `system` is the one the run was given, which the GPU's errors are reported under. + */ +export const execute = async (context: ExecuteContext, schedule: Schedule, args: unknown, system: { readonly name: string }): Promise => { + const { gpu } = context.state; + if (gpu === null) return executeTiers(context, schedule, args, null); + const lost = gpu.lost(); + // Lost since the run started; the world sets gpuDevice to null before the next one. + if (lost !== null) throw new Error(`The world's GPU device was lost during this run: ${lost}.`); + // An earlier run's GPU work reports its errors to this run: waiting for them there would hold every run until the + // GPU process catches up. + const earlier = gpu.takeError(); + const failures = earlier === null ? null : [failureOf("gpu", earlier.name, earlier.message)]; + gpu.device.pushErrorScope("out-of-memory"); + gpu.device.pushErrorScope("validation"); + try { + return await executeTiers(context, schedule, args, failures); + } + finally { + gpu.reportErrors(system, gpu.device.popErrorScope(), gpu.device.popErrorScope()); + } +}; + +/** + * A run's fold into one reduced resource: it starts at the operator's identity, and is written once its last + * contributor finishes, unless one failed or none ran its rows. + */ +type Fold = { target: ReductionTarget | null; readonly values: number[]; remaining: number; ran: boolean; failed: boolean }; + +/** Folds a contributor's leaves into its run's folds, writing each resource whose contributors have all finished. */ +const contribute = (state: State, folds: ReadonlyMap, runner: Runner, folded: readonly Float64Array[] | null, failed: boolean): void => { + runner.targets.forEach((target, t) => { + const fold = folds.get(target.key) ?? folds.get(firstKeyOf(target.key)); + if (fold === undefined) return; + fold.target ??= target; + if (fold.values.length === 0) fold.values.push(...target.leaves.map(() => identityOf(target.operator))); + const leaves = folded?.[t]; + if (failed) fold.failed = true; + else if (leaves !== undefined) { + fold.ran = true; + for (let i = 0; i < leaves.length; i++) fold.values[i] = combine(target.operator, fold.values[i] ?? NaN, leaves[i] ?? NaN); + } + if (--fold.remaining === 0 && fold.ran && !fold.failed) writeReduction(state, target, fold.values, 0, state.tick); + }); +}; + +/** + * A schedule's runners by tier, and an array per tier for their chunks, made on its first run and reused. Runners are + * read again each run, since moving to or from a GPU device replaces them. + */ +type Prepared = { + readonly runners: Runner[][]; + /** How many of each tier's runners run on joined threads. */ + readonly remotes: number[]; + /** The indexes each tier's runners look up through, refreshed before it. */ + readonly uses: (readonly IndexUse[])[]; + /** The world's `runnersVersion` its runners were read at. */ + version: number; + readonly started: (readonly Chunk[])[][]; + /** For tasks on joined threads: the systems of the one being posted and their tiers in it, and by channel, the tier's task. */ + readonly systems: RemoteSystem[]; + readonly tiers: number[]; + readonly tasks: Map>; + /** + * The run's warnings, the first `warningCount`, and the last report made of warnings alone, reused while a run's + * are the same. Counted rather than truncated: setting an array's length costs a run more than all of this. + */ + readonly warnings: Warning[]; + warningCount: number; + warned: RunReport | null; + /** The run's failures and skipped systems, allocated only when there's something to list. */ + readonly outcome: Outcome; + /** The placement its tails were found for, and they: the schedule without them, and them in schedule order. */ + placement: number; + tailing: Tailing | null; +}; + +/** A schedule's tails: the schedule without them, and their runners, leaves and tiers in schedule order. */ +type Tailing = { readonly schedule: Schedule; readonly runners: Runner[]; readonly leaves: readonly Leaf[]; readonly tiers: readonly number[] }; + +/** Finds a schedule's tails where its systems are placed now; null without any. */ +const tailingOf = (context: ExecuteContext, schedule: Schedule): Tailing | null => { + const flags = tailsOf(schedule, context.runnerOf); + const leaves: Leaf[] = []; + const tiers: number[] = []; + const rest: Leaf[][] = []; + schedule.tiers.forEach((tier, t) => { + const kept = tier.filter((leaf, i) => { + if (!flags[t][i]) return true; + leaves.push(leaf); + tiers.push(t); + return false; + }); + if (kept.length > 0) rest.push(kept); + }); + if (leaves.length === 0) return null; + return { schedule: { tiers: rest, reductions: schedule.reductions, observer: schedule.observer }, runners: leaves.map(context.runnerOf), leaves, tiers }; +}; + +type Outcome = { failures: Failure[] | null; skipped: string[] | null }; +const preparedBy = new WeakMap>(); + +const preparedFor = (context: ExecuteContext, schedule: Schedule): Prepared => { + let bySchedule = preparedBy.get(context); + if (bySchedule === undefined) preparedBy.set(context, bySchedule = new WeakMap()); + let prepared = bySchedule.get(schedule); + if (prepared === undefined) { + prepared = { + runners: schedule.tiers.map(tier => tier.map(context.runnerOf)), remotes: schedule.tiers.map(() => 0), uses: schedule.tiers.map(() => []), version: -1, + started: schedule.tiers.map(tier => new Array(tier.length)), + systems: [], tiers: [], tasks: new Map(), warnings: [], warningCount: 0, warned: null, outcome: { failures: null, skipped: null }, + placement: -1, tailing: null, + }; + bySchedule.set(schedule, prepared); + } + if (prepared.version !== context.state.runnersVersion) refresh(context, schedule, prepared); + return prepared; +}; + +/** Reads a schedule's runners again, once one was replaced, as moving to or from a GPU device or a joined thread does. */ +const refresh = (context: ExecuteContext, schedule: Schedule, prepared: Prepared): void => { + for (let t = 0; t < schedule.tiers.length; t++) { + const tier = schedule.tiers[t]; + const runners = prepared.runners[t]; + let remote = 0; + for (let i = 0; i < tier.length; i++) if ((runners[i] = context.runnerOf(tier[i])).remote !== null) remote++; + prepared.remotes[t] = remote; + prepared.uses[t] = mergeUses(runners); + } + prepared.version = context.state.runnersVersion; +}; + +/** + * Drains a worker's segment into a buffer of its own, queued in its place: commands, spawn blocks staged to commit in + * its place, and the reservations it left unused. Fails if it doesn't start with its task's merge tag. + */ +const drain = (state: State, { bytes, tag }: Segment): Error | undefined => { + const buffer = state.drains[state.drained] ??= createBuffer(); + state.drained++; + if (!drainSegment(buffer, bytes, state.layouts, state.starts, state.unused, tag)) return new Error("A worker's commands came back under another task's merge tag."); + // Systems can't touch the resource entity; only registration adds resources. + if (buffer.count > 0 || buffer.stagedCount > 0) state.queued.push({ buffer, guarded: true }); + return undefined; +}; + +/** + * Queues a settled chunk's commands, a worker's drained from its segment, and returns its error, if it failed. Once a + * joined thread fills objects, values given them here are checked, each command giving another value dropped. + */ +const queue = (state: State, { commands, segment, error }: ChunkResult): unknown => { + const drained = segment === undefined ? undefined : drain(state, segment); + let checked: Error | undefined; + if (state.claimed) { + checked = checkCommands(state, commands); + if (segment !== undefined) checked ??= checkCommands(state, state.drains[state.drained - 1]); + } + if (commands.count > 0 || commands.stagedCount > 0) state.queued.push({ buffer: commands, guarded: true }); + return error ?? drained ?? checked; +}; + +/** + * Queues the commands a joined thread's system gave back, drained from its segment with the thread's numbers for keys + * mapped, and returns its error, if it failed. Values it gave its own objects are its slots; others are checked. + */ +const queueRemote = (state: State, { channel, result }: RemoteSystem): unknown => { + const { segment } = result; + if (segment === undefined) return result.error; + const buffer = state.drains[state.drained] ??= createBuffer(); + state.drained++; + if (!drainSegment(buffer, segment.bytes, state.layouts, state.starts, state.unused, segment.tag, channel.local)) { + return result.error ?? new Error("A joined thread's commands came back under another task's merge tag, or name a key this world never imported."); + } + const checked = checkCommands(state, buffer, channel.log); + if (buffer.count > 0 || buffer.stagedCount > 0) state.queued.push({ buffer, guarded: true }); + return result.error ?? checked; +}; + +/** What a joined thread's system gave back once its task settled: `gone` while its thread is gone, else its error. */ +const gone = Symbol("gone"); +const settleRemote = (state: State, remote: RemoteSystem, done: boolean): unknown => done ? queueRemote(state, remote) : gone; + +/** Whether every runner of a tier runs on `channel`'s thread. */ +const allOn = (runners: readonly Runner[], channel: TaskChannel): boolean => { + for (let i = 0; i < runners.length; i++) if (runners[i].remote?.channel !== channel) return false; + return runners.length > 0; +}; + +/** Once a sync point applied or failed: empties the buffers workers' and joined threads' segments drained into, and their command rings. */ +const rewind = (state: State, workers: Workers | null): void => { + for (let k = 0; k < state.drained; k++) clearBuffer(state.drains[k]); + state.drained = 0; + workers?.pool.rewind(); + for (let k = 0; k < state.channels.length; k++) state.channels[k].rewind(); +}; + +const firstOf = (first: unknown, next: unknown): unknown => first === undefined ? next : first; + +const isResults = (result: ChunkResult | readonly ChunkResult[]): result is readonly ChunkResult[] => Array.isArray(result); + +/** + * A system's outcome in `outcome`: a failure, or a skip with a `"thread"` failure, and its fold. Returns whether it + * failed, which stops the run after its tier. + */ +const settled = (state: State, folds: ReadonlyMap | null, outcome: Outcome, runner: Runner, leaf: { readonly name: string }, error: unknown): boolean => { + if (error === gone) { + const label = labelOfItem(leaf); + (outcome.failures ??= []).push(failureOf("thread", label, runner.remote?.channel.goneError())); + (outcome.skipped ??= []).push(label); + if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, null, true); + return false; + } + const failed = error !== undefined; + if (failed) (outcome.failures ??= []).push(failureOf("system", labelOfItem(leaf), error)); + if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, runner.finish(failed), failed); + return failed; +}; + +/** + * Adds the systems of `runners` on `channel`'s thread to the task being gathered, with tier `tier` within it. One + * removed since its tier was read, by a body that ran meanwhile, is left out, giving back nothing, as one its thread + * skipped: the thread drops it once the tasks posted before its removal are done, so a later task can't name it. The + * caller posts the task with no await between, and `channel` has none in flight, so nothing else reads the result + * reset. + */ +const gather = (prepared: Prepared, runners: readonly Runner[], channel: TaskChannel, tier: number): void => { + for (let i = 0; i < runners.length; i++) { + const { remote } = runners[i]; + if (remote === null || remote.channel !== channel) continue; + if (runners[i].holdings.removed) { + const { result } = remote; + result.flags = 0; + result.segment = undefined; + result.error = undefined; + continue; + } + liftColumnStamps(runners[i]); + prepared.systems.push(remote); + prepared.tiers.push(tier); + prepared.warnings[prepared.warningCount++] = remote.warning; + } +}; + +/** Posts the task gathered, on `channel`'s thread. */ +const post = (state: State, prepared: Prepared, channel: TaskChannel, args: unknown): Promise => { + const task = channel.run(state, prepared.systems, prepared.tiers, args); + prepared.systems.length = 0; + prepared.tiers.length = 0; + return task; +}; + +/** A task never posted, as for a thread that's gone. */ +const notDone = Promise.resolve(false); + +/** + * Runs tiers from `first` on, as long as each is all on `channel`'s thread, as one task, with no barrier between them; + * returns the last of them, or -1 when one failed, the tiers after it skipped. + */ +const runFused = async (context: ExecuteContext, schedule: Schedule, prepared: Prepared, folds: ReadonlyMap | null, args: unknown, first: number, channel: TaskChannel): Promise => { + const { state } = context; + const { gpu } = state; + let last = first; + if (prepared.version !== state.runnersVersion) refresh(context, schedule, prepared); + // A tier using an index starts a task of its own, after the index is refreshed for it. + while (last + 1 < schedule.tiers.length && allOn(prepared.runners[last + 1], channel) && prepared.uses[last + 1].length === 0) last++; + if (channel.busy()) await channel.idle(); + for (let v = first; v <= last; v++) { + if (state.tails.length !== 0) { + const waiting = settleRunners(state, prepared.runners[v]); + if (waiting !== null) await waiting; + } + if (state.observer.posts.length !== 0) { + const waiting = settleObserverRunners(state, prepared.runners[v]); + if (waiting !== null) await waiting; + } + if (gpu !== null) await readAhead(state, gpu, prepared.runners[v]); + } + // Gathered once nothing more is awaited, so a system removed while this waited is in no tier of the task. + for (let v = first; v <= last; v++) gather(prepared, prepared.runners[v], channel, v - first); + const done = await post(state, prepared, channel, args); + const { outcome } = prepared; + for (let v = first; v <= last; v++) { + const leaves = schedule.tiers[v]; + const runners = prepared.runners[v]; + countRan(state, runners); + let failed = false; + for (let i = 0; i < runners.length; i++) { + const { remote } = runners[i]; + if (remote !== null && settled(state, folds, outcome, runners[i], leaves[i], settleRemote(state, remote, done))) failed = true; + } + if (gpu !== null) markWrites(state, gpu, runners); + if (failed) { + for (let later = v + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(labelOfItem(leaf)); + return -1; + } + } + return last; +}; + +const executeTiers = async (context: ExecuteContext, whole: Schedule, args: unknown, earlier: Failure[] | null): Promise => { + const { state, kernelIndexOf, workers, publish } = context; + const { gpu } = state; + context.tails = null; + startRan(state.ran); + let schedule = whole; + let prepared = preparedFor(context, whole); + if (prepared.placement !== state.placement) { + prepared.placement = state.placement; + prepared.tailing = state.channels.length === 0 ? null : tailingOf(context, whole); + } + const { tailing } = prepared; + if (tailing !== null) { + schedule = tailing.schedule; + prepared = preparedFor(context, schedule); + } + let folds: Map | null = null; + for (const [key, contributors] of schedule.reductions) (folds ??= new Map()).set(key, { target: null, values: [], remaining: contributors, ran: false, failed: false }); + let dropped = 0; + const { outcome } = prepared; + prepared.warningCount = 0; + outcome.failures = earlier; + outcome.skipped = null; + try { + for (let t = 0; t < schedule.tiers.length; t++) { + const tier = schedule.tiers[t]; + // A tick in progress takes up runners replaced since it started from its next tier on. + if (prepared.version !== state.runnersVersion) refresh(context, schedule, prepared); + const runners = prepared.runners[t]; + const remote = prepared.remotes[t]; + if (state.tails.length !== 0) { + const waiting = settleRunners(state, runners); + if (waiting !== null) await waiting; + } + if (state.observer.posts.length !== 0) { + const waiting = settleObserverRunners(state, runners); + if (waiting !== null) await waiting; + } + const uses = prepared.uses[t]; + if (uses.length !== 0) { + const building = refreshUses(state, uses, workers?.pool ?? null); + if (building !== null) await building; + if (state.indexFailures.length !== 0) (outcome.failures ??= []).push(...state.indexFailures.splice(0)); + } + const only = remote === runners.length ? runners[0]?.remote?.channel : undefined; + if (only !== undefined && allOn(runners, only)) { + const last = await runFused(context, schedule, prepared, folds, args, t, only); + if (last < 0) break; + t = last; + continue; + } + const started = prepared.started[t]; + if (gpu !== null) await readAhead(state, gpu, runners); + // Systems on joined threads start first, so their threads run while this one does. + if (remote > 0) { + const { tasks } = prepared; + tasks.clear(); + for (let i = 0; i < runners.length; i++) { + const channel = runners[i].remote?.channel; + if (channel === undefined || tasks.has(channel)) continue; + if (channel.busy()) await channel.idle(); + gather(prepared, runners, channel, 0); + tasks.set(channel, post(state, prepared, channel, args)); + } + } + for (let i = 0; i < runners.length; i++) if (runners[i].kernel !== null) started[i] = runners[i].start(args, workers, kernelIndexOf(runners[i])); + for (let i = 0; i < runners.length; i++) if (runners[i].kernel === null && runners[i].remote === null) started[i] = runners[i].start(args, null, -1); + // Every chunk settles, even a failed one, so no worker is still writing at the sync point. Chunks the main + // thread ran are settled already, so only those workers run are awaited. + let tierFailed = false; + for (let i = 0; i < runners.length; i++) { + const runner = runners[i]; + const remoteSystem = runner.remote; + if (remoteSystem !== null) { + if (settled(state, folds, outcome, runner, tier[i], settleRemote(state, remoteSystem, await (prepared.tasks.get(remoteSystem.channel) ?? notDone)))) tierFailed = true; + continue; + } + let error: unknown = undefined; + // Indexed: an iterator kept across an await would be allocated for every system. + const chunks = started[i]; + for (let c = 0; c < chunks.length; c++) { + const chunk = chunks[c]; + const result = chunk instanceof Promise ? await chunk : chunk; + // Every chunk's commands queue, a failed system's included; its first error stands for it. + if (isResults(result)) for (const each of result) error = firstOf(error, queue(state, each)); + else error = firstOf(error, queue(state, result)); + } + started[i] = noChunks; + const failed = error !== undefined; + if (failed) { + (outcome.failures ??= []).push(failureOf("system", labelOfItem(tier[i]), error)); + tierFailed = true; + } + if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, runner.finish(failed), failed); + } + if (remote > 0) prepared.tasks.clear(); + countRan(state, runners); + if (gpu !== null) markWrites(state, gpu, runners); + if (tierFailed) { + for (let later = t + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(labelOfItem(leaf)); + break; + } + } + } + finally { + // This run's tasks on joined threads are done: they return to their event loops while its sync point applies. + for (let k = 0; k < state.logs.length; k++) markRun(state.logs[k]); + try { + if (gpu !== null) { + gpu.submit(); + gpu.beforeSync(); + } + try { + // The report's errors known so far join this sync point. + context.beforeSync(outcome.failures, outcome.skipped, prepared.warnings, prepared.warningCount); + // A sync point counts as a write to everything a tail still reads. + if (state.tails.length !== 0) { + activate(state); + const waiting = syncPending(state) ? settleAll(state) : null; + if (waiting !== null) await waiting; + } + // Each sync point waits for observer systems on joined threads in progress, and cancels the rest. + if (state.observer.posts.length !== 0) { + const waiting = settlePosts(state); + if (waiting !== null) await waiting; + } + if (prepareSync(state)) { + try { + if (gpu !== null) { + // The sync point moves and writes existing rows of these archetypes on the CPU, and + // finalizers read them, so it needs the GPU's writes there first. + const disturbed = disturbedBy(state); + gpu.disturb(disturbed.archetypes); + if (gpu.anyAhead()) await gpu.readback(disturbed.storages); + } + } + finally { + // Applied even when the readback fails, which only a lost device makes it do: the CPU state + // stays whole. + dropped = state.plan.dropped; + for (const { finalizer, error, kind } of applySync(state)) (outcome.failures ??= []).push(failureOf(kind ?? "finalizer", labelOfItem(finalizer), error)); + } + } + } + finally { + // Even a sync point that failed partway moved rows. + if (gpu !== null && state.touched !== null) { + gpu.touched(state.touched); + state.touched.clear(); + } + } + } + finally { + // Strings let go before the last run's sync point have no reader left: this run's waited for every one. + state.strings?.settle(); + rewind(state, workers); + publish(); + } + } + if (tailing !== null) { + // Placed again since, as when a joined thread is gone: they're found again next run. + if (tailing.runners.some((runner, k) => runner !== context.runnerOf(tailing.leaves[k]))) prepared.placement = -1; + else { + if (gpu !== null) await readAhead(state, gpu, tailing.runners); + // A thread runs one task at a time: the last tick's tail there, if it's still running, first. + for (const runner of tailing.runners) if (runner.remote?.channel.busy() === true) await runner.remote.channel.idle(); + context.tails = postTails(state, tailing.runners, tailing.leaves, tailing.tiers, args); + countRan(state, tailing.runners); + } + } + // Conflicts unique indexes found while refreshing for finalizers. + if (state.indexFailures.length !== 0) (outcome.failures ??= []).push(...state.indexFailures.splice(0)); + const { failures, skipped } = outcome; + // Released for collection: the next run starts them again. + outcome.failures = outcome.skipped = null; + const { warningCount } = prepared; + if (failures === null && skipped === null && dropped === 0) return warningCount === 0 ? clean : warnedReport(prepared); + return { failures: failures ?? none, skipped: skipped ?? none, dropped, warnings: warningCount === 0 ? none : prepared.warnings.slice(0, warningCount) }; +}; + +/** A report of warnings alone: the last one made, while a run's warnings are the same, so a steady run allocates none. */ +const warnedReport = (prepared: Prepared): RunReport => { + const { warnings, warningCount, warned } = prepared; + if (warned !== null && warned.warnings.length === warningCount) { + let same = true; + for (let k = 0; k < warningCount && same; k++) same = warned.warnings[k] === warnings[k]; + if (same) return warned; + } + return prepared.warned = { failures: none, skipped: none, dropped: 0, warnings: warnings.slice(0, warningCount) }; +}; + +/** + * What the planned sync point disturbs: the archetypes whose existing rows it moves or writes, those of every planned + * entity that existed before it, and the resource entity's when finalizers run; and every storage it reads, theirs + * and those finalizers look up. Spawns only add rows at the end of their archetypes. + */ +const disturbedBy = (state: State): { readonly archetypes: Set; readonly storages: readonly Storage[] } => { + const { plan, archetypes } = state; + const disturbed = new Set(); + for (let entry = 0; entry < plan.count; entry++) if (plan.from[entry] >= 0) disturbed.add(archetypes[plan.from[entry]]); + const looked = new Set(); + if (state.finalizers.length > 0) { + const index = state.resourceEntity < 0 ? -1 : locate(state.entities, state.resourceEntity); + if (index >= 0) disturbed.add(archetypes[state.entities.archetype[index]]); + for (const { finalizer } of state.finalizers) { + for (const name in finalizer.declaration.lookup) for (const key of finalizer.declaration.lookup[name].read ?? []) looked.add(idOf(state, key)); + } + } + const storages = [...disturbed].flatMap(archetype => archetype.storages); + if (looked.size > 0) for (const archetype of archetypes) for (const id of looked) { + const storage = archetype.storageById[id]; + if (storage !== undefined && !disturbed.has(archetype)) storages.push(storage); + } + return { archetypes: disturbed, storages }; +}; + +/** Before CPU systems read or write a storage, in their query or through a lookup, the GPU's writes to it come back. */ +export const readAhead = async (state: State, gpu: GpuEngine, runners: readonly Runner[]): Promise => { + if (!gpu.anyAhead()) return; + const storages = new Set(); + const add = (archetype: Archetype, ids: readonly number[]): void => { + for (const id of ids) { + const storage = archetype.storageById[id]; + if (storage !== undefined && gpu.isAhead(storage)) storages.add(storage); + } + }; + for (const runner of runners) { + if (runner.placement !== "cpu") continue; + for (const archetype of runner.matched()) add(archetype, runner.touches); + if (runner.looks.length > 0) for (const archetype of state.archetypes) add(archetype, runner.looks); + // What its indexes hold entities by, so they're kept by the values the GPU wrote. + for (const { index } of runner.indexes) for (const archetype of state.archetypes) { + add(archetype, index.keys); + add(archetype, index.has); + } + } + if (storages.size === 0) return; + if (state.tails.length !== 0) { + const waiting = settleKeys(state, [...storages].map(storage => storage.component), []); + if (waiting !== null) await waiting; + } + if (state.observer.posts.length !== 0) { + const waiting = settleObserverKeys(state, [...storages].map(storage => storage.component), []); + if (waiting !== null) await waiting; + } + await gpu.readback([...storages]); +}; + +/** + * CPU writes make GPU copies out of date; GPU kernels mark their own writes as they dispatch. Writes to a component + * held on the GPU stamp its chunks, which uploads read, so only other storages are marked whole. + */ +const markWrites = (state: State, gpu: GpuEngine, runners: readonly Runner[]): void => { + for (const runner of runners) { + if (runner.placement !== "cpu" || runner.writes.length === 0) continue; + for (const archetype of runner.matched()) { + for (const id of runner.writes) { + const storage = archetype.storageById[id]; + if (storage !== undefined && !(storage.ticked && state.resident.has(id))) gpu.markCpuWrites(archetype, storage); + } + } + } +}; diff --git a/packages/ecs/src/implementation/world/failures.ts b/packages/ecs/src/implementation/world/failures.ts new file mode 100644 index 000000000..0408780d6 --- /dev/null +++ b/packages/ecs/src/implementation/world/failures.ts @@ -0,0 +1,14 @@ +import type { Failure, Warning } from '../../interface/report/failure.js'; + +/** What went wrong in a run or tick, before the world spawns its report: empty lists and a zero when it's clean. */ +export type RunReport = { + readonly failures: readonly Failure[]; + readonly skipped: readonly string[]; + readonly dropped: number; + readonly warnings: readonly Warning[]; +}; + +/** A failure as data: an Error's message and stack, or String() of anything else thrown. */ +export const failureOf = (kind: Failure["kind"], name: string, error: unknown): Failure => error instanceof Error + ? { kind, name, message: error.message, stack: typeof error.stack === "string" ? error.stack : null } + : { kind, name, message: String(error), stack: null }; diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts new file mode 100644 index 000000000..3d303942e --- /dev/null +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; +import { identify } from "../../interface/registry/ids.test-helper.js"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { Rule } from "../../interface/rule/rule.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { count, read, readEach, readResource, select } from "./reads.test-helper.js"; +import { workers } from "../../interface/resources/workers.js"; +import { reportOf } from "./reports.test-helper.js"; + +const handle = Symbol("finalizersTest/handle"); +const hidden = Symbol("finalizersTest/hidden"); +const source = Symbol("finalizersTest/source"); +const slot = Symbol("finalizersTest/slot"); +const parent = Symbol("finalizersTest/parent"); +const label = Symbol("finalizersTest/label"); +const released = Symbol("finalizersTest/released"); +const late = Symbol("finalizersTest/late"); +const level = Symbol("finalizersTest/level"); +const written = Symbol("finalizersTest/written"); +// Finalizers apply in every world, so each test's finalizers match components of their own. +const counted = Symbol("finalizersTest/counted"); +const pendingHandle = Symbol("finalizersTest/pendingHandle"); +const respawned = Symbol("finalizersTest/respawned"); +const ordered = Symbol("finalizersTest/ordered"); +const writing = Symbol("finalizersTest/writing"); +const disposing = Symbol("finalizersTest/disposing"); +const reloaded = Symbol("finalizersTest/reloaded"); + +declare module "@adobe/ecs" { + export interface Components { + [reloaded]: { type: number }; + [handle]: { type: number }; + [hidden]: { type: true }; + [source]: { type: number }; + [slot]: { type: number; derived: true }; + [parent]: { type: Entity; reference: "owner" }; + [label]: { type: string }; + [late]: { type: number }; + [counted]: { type: number }; + [pendingHandle]: { type: number }; + [respawned]: { type: number }; + [ordered]: { type: number }; + [writing]: { type: number }; + [disposing]: { type: number }; + } + export interface Resources { + [released]: { type: number }; + [level]: { type: number }; + [written]: { type: number }; + } +} + +Component.register(handle, { type: { type: "number" } }); +Component.register(hidden, { type: { const: true } }); +Component.register(source, { type: { type: "number" } }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(label, { type: { type: "string" } }); +Component.register(late, { type: { type: "number" } }); +Resource.register(released, { type: { type: "number", default: 0 } }); +Resource.register(level, { type: { type: "number", default: 0 } }); +Resource.register(written, { type: { type: "number", default: 0 } }); +Component.register(counted, { type: { type: "number" } }); +Component.register(pendingHandle, { type: { type: "number" } }); +Component.register(respawned, { type: { type: "number" } }); +Component.register(ordered, { type: { type: "number" } }); +Component.register(writing, { type: { type: "number" } }); +Component.register(disposing, { type: { type: "number" } }); +Component.register(reloaded, { type: { type: "number" } }); + +Rule.create({ name: "finalizersTest/slots", components: { has: [source] }, derive: [slot] }); + +let steps: ((commands: System.Commands) => void)[] = []; +const edit = System.create({ name: "finalizersTest/edit", input: { type: "object", properties: { step: { type: "integer" } } } }, ({ input, commands }) => { + steps[input.step]?.(commands); +}); +const step = (world: ReturnType) => (run: (commands: System.Commands) => void) => { + steps = [...steps, run]; + return world.run(edit, { step: steps.length - 1 }); +}; + +describe("finalizers", () => { + it("run for each entity that stops matching, by any cause, reading its values before the sync point applies", async () => { + const seen: [Entity, number][] = []; + Finalizer.create({ name: "finalizersTest/release", components: { read: [handle], not: [hidden] } }, row => { seen.push([row.entity, row[handle]]); }); + const world = createWorld({ [workers]: 0 }); + const run = step(world); + const spawned: Entity[] = []; + await run(commands => { for (let i = 0; i < 6; i++) spawned.push(commands.spawn([handle])({ [handle]: i * 10 })); }); + const [despawned, removed, excluded, replaced, kept, rewritten] = spawned; + await run(commands => { + commands.despawn(despawned); + commands.remove(removed, handle); + commands.add(excluded, hidden, true); + commands.remove(replaced, handle); + commands.add(replaced, handle, 99); + commands.add(rewritten, handle, 55); + // Spawned and despawned at once: it never matched. + commands.despawn(commands.spawn([handle])({ [handle]: 1 })); + }); + expect(seen).toEqual([[despawned, 0], [removed, 10], [excluded, 20], [replaced, 30]]); + expect(await read(world, replaced, handle)).toBe(99); + expect(await read(world, kept, handle)).toBe(40); + await world.dispose(); + }); + + it("run for rules' and references' removals too, and at disposal for every live match", async () => { + const seen: number[] = []; + Finalizer.create({ name: "finalizersTest/slotReleased", components: { read: [slot, source] } }, row => { seen.push(row[source]); }); + const world = createWorld({ [workers]: 0 }); + const run = step(world); + let root = Entity.none, child = Entity.none; + await run(commands => { + root = commands.spawn([source])({ [source]: 1 }); + child = commands.spawn([source, parent])({ [source]: 2, [parent]: root }); + commands.spawn([source])({ [source]: 3 }); + }); + await run(commands => commands.despawn(root)); + expect(seen.sort()).toEqual([1, 2]); + expect(await read(world, child, source)).toBeNull(); + await world.dispose(); + expect(seen.sort()).toEqual([1, 2, 3]); + }); + + it("write resources at the sync point, queue commands for the next one, and see lookups as they were", async () => { + const found: (number | null)[] = []; + Finalizer.create({ + name: "finalizersTest/count", + components: { read: [counted] }, + lookup: { handles: { read: [counted] } }, + resources: { write: [released] }, + }, (row, { resources, lookup, commands }) => { + resources[released] += 1; + found.push(lookup.handles(row.entity)?.[counted] ?? null); + commands.spawn([label])({ [label]: `after ${row[counted]}` }); + }); + const world = createWorld({ [workers]: 0 }); + const run = step(world); + let a = Entity.none; + await run(commands => { a = commands.spawn([counted])({ [counted]: 5 }); }); + await run(commands => commands.despawn(a)); + expect(found).toEqual([5]); + expect(await readResource(world, released)).toBe(1); + expect(await count(world, { required: [label] })).toBe(0); + await run(() => {}); + expect(await count(world, { required: [label] })).toBe(1); + await world.dispose(); + }); + + it("leave their commands for the next run's sync point, even when that run first adds resources", async () => { + let other = Entity.none; + Finalizer.create({ name: "finalizersTest/pending", components: { read: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); + const reads = System.create({ name: "finalizersTest/reads", resources: { read: [level] } }, ({ resources }) => { void resources[level]; }); + // Read after every run, as a read through a run of its own would apply the commands left for it. + let seen: string | null = null; + const watch = System.create({ name: "finalizersTest/watchOther", observer: true, lookup: { labels: { read: [label] } } }, ({ lookup }) => { + seen = lookup.labels(other)?.[label] ?? null; + }); + const world = createWorld({ [workers]: 0 }); + world.add(watch); + const run = step(world); + let a = Entity.none; + await run(commands => { a = commands.spawn([pendingHandle])({ [pendingHandle]: 1 }); other = commands.spawn([label])({ [label]: "other" }); }); + await run(commands => commands.despawn(a)); + expect(seen).toBe("other"); + await world.run(reads); + expect(seen).toBeNull(); + await world.dispose(); + }); + + it("keep reservations they make apart from the indexes their sync point frees", async () => { + Finalizer.create({ name: "finalizersTest/respawn", components: { read: [respawned] } }, (row, { commands }) => { + commands.spawn([respawned])({ [respawned]: row[respawned] + 100 }); + }); + const world = createWorld({ [workers]: 0 }); + const run = step(world); + const first: Entity[] = []; + await run(commands => { for (let i = 0; i < 8; i++) first.push(commands.spawn([respawned])({ [respawned]: i })); }); + await run(commands => { for (const entity of first) commands.despawn(entity); }); + await run(commands => { for (let i = 0; i < 8; i++) commands.spawn([respawned])({ [respawned]: 1000 + i }); }); + const live = await select(world, { required: [respawned] }); + expect(new Set(live).size).toBe(16); + expect((await readEach(world, live, respawned)).sort((x, y) => (x ?? 0) - (y ?? 0))) + .toEqual([100, 101, 102, 103, 104, 105, 106, 107, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007]); + await world.dispose(); + }); + + it("run in their order, and a throw still applies the sync point and is reported", async () => { + const order: string[] = []; + const second = Finalizer.create({ name: "finalizersTest/second", components: { read: [ordered] } }, () => { order.push("second"); }); + Finalizer.create({ name: "finalizersTest/first", components: { read: [ordered] }, before: [second] }, () => { order.push("first"); }); + Finalizer.create({ name: "finalizersTest/throws", components: { read: [ordered] } }, () => { throw new Error("finalizer failed"); }); + const world = createWorld({ [workers]: 0 }); + const run = step(world); + let a = Entity.none; + await run(commands => { a = commands.spawn([ordered])({ [ordered]: 1 }); }); + const { failures } = await reportOf(world, await run(commands => commands.despawn(a))); + expect(failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "finalizer", name: "finalizersTest/throws", message: "finalizer failed" }]); + expect(order).toEqual(["first", "second"]); + expect(await read(world, a, handle)).toBeNull(); + const writer = (name: "finalizersTest/writerA" | "finalizersTest/writerB") => + Finalizer.create({ name, components: { read: [writing] }, resources: { write: [written] } }, (_, { resources }) => { resources[written] = 1; }); + writer("finalizersTest/writerA"); + expect(() => writer("finalizersTest/writerB")).toThrow("order them with before or after"); + await world.dispose(); + }); +}); + +describe("a finalizer declared again the same, as a module evaluated again declares it", () => { + it("keeps its place in each world, and runs its new body once", async () => { + const seen: string[] = []; + const first = Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("first"); }); + const world = createWorld({ [workers]: 0 }); + let entity = Entity.none; + await step(world)(commands => { entity = commands.spawn([reloaded])({ [reloaded]: 1 }); }); + const again = Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("again"); }); + expect(again).toBe(first); + await step(world)(commands => { commands.despawn(entity); }); + expect(seen).toEqual(["again"]); + expect(() => Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [handle] } }, "finalizersTest/finalizers.ts#reloaded"), () => {})).toThrow("reload the page"); + await world.dispose(); + }); +}); + +describe("a finalizer created after a world has entities it matches", () => { + it("releases them at disposal", async () => { + const world = createWorld({ [workers]: 0 }); + await step(world)(commands => { commands.spawn([disposing])({ [disposing]: 4 }); }); + const seen: number[] = []; + Finalizer.create({ name: "finalizersTest/late", components: { read: [disposing] }, resources: { read: [level] } }, row => { seen.push(row[disposing]); }); + await world.dispose(); + expect(seen).toEqual([4]); + }); +}); + +describe("changes finalizers", () => { + it("watch by default every component that isn't derived, including ones first used later, and no resources", async () => { + const seen: unknown[] = []; + Finalizer.changes({ name: "finalizersTest/everything", resources: { read: [level] } }, (changes, { resources }) => { + void resources[level]; + for (const group of changes.groups) seen.push(group.to); + }); + const world = createWorld({ [workers]: 0 }); + await step(world)(commands => { commands.spawn([late])({ [late]: 1 }); }); + expect(seen).toEqual([[late]]); + await world.dispose(); + }); + + it("see each sync point's structural changes to their components, grouped, with values before and after", async () => { + const groups: unknown[] = []; + const values: unknown[] = []; + Finalizer.changes({ name: "finalizersTest/changes", watch: [handle, label] }, ({ groups: seen, previous, next }) => { + for (const group of seen) { + groups.push({ ...group, entities: group.entities.length }); + for (const entity of group.entities) values.push([previous(entity, handle), next(entity, handle)]); + } + }); + const world = createWorld({ [workers]: 0 }); + const run = step(world); + let a = Entity.none, b = Entity.none; + await run(commands => { a = commands.spawn([handle])({ [handle]: 1 }); b = commands.spawn([handle, label])({ [handle]: 2, [label]: "b" }); commands.spawn([hidden])({ [hidden]: true }); }); + expect(groups).toEqual([ + { from: null, to: [handle], added: [handle], removed: [], replaced: [], entities: 1 }, + { from: null, to: [handle, label], added: [handle, label], removed: [], replaced: [], entities: 1 }, + ]); + expect(values).toEqual([[null, 1], [null, 2]]); + groups.length = values.length = 0; + await run(commands => { commands.add(a, [label, handle], { [label]: "a", [handle]: 3 }); commands.despawn(b); commands.add(a, hidden, true); }); + expect(groups).toEqual([ + { from: [handle], to: [handle, label], added: [label], removed: [], replaced: [handle], entities: 1 }, + { from: [handle, label], to: null, added: [], removed: [handle, label], replaced: [], entities: 1 }, + ]); + expect(values).toEqual([[1, 3], [2, null]]); + await world.dispose(); + }); + + it("see a value an add gives a component the entity keeps as replaced, removed and added again or not", async () => { + const groups: unknown[] = []; + const values: unknown[] = []; + Finalizer.changes({ name: "finalizersTest/replacing", watch: [handle, label] }, ({ groups: seen, previous, next }) => { + for (const group of seen) { + groups.push({ ...group, entities: group.entities.length }); + for (const entity of group.entities) values.push([previous(entity, handle), next(entity, handle)]); + } + }); + const world = createWorld({ [workers]: 0 }); + const run = step(world); + let a = Entity.none, b = Entity.none, c = Entity.none; + await run(commands => { a = commands.spawn([handle, label])({ [handle]: 1, [label]: "a" }); b = commands.spawn([handle, label])({ [handle]: 2, [label]: "b" }); c = commands.spawn([handle, label])({ [handle]: 3, [label]: "c" }); }); + groups.length = values.length = 0; + await run(commands => { + commands.add(a, handle, 10); + commands.add(a, handle, 11); + commands.remove(b, handle); + commands.add(b, handle, 20); + commands.add(c, handle, 30); + commands.remove(c, handle); + }); + expect(groups).toEqual([ + { from: [handle, label], to: [handle, label], added: [], removed: [], replaced: [handle], entities: 2 }, + { from: [handle, label], to: [label], added: [], removed: [handle], replaced: [], entities: 1 }, + ]); + expect(values).toEqual([[1, 11], [2, 20], [3, null]]); + groups.length = values.length = 0; + // Consecutive entries between the same archetypes, adding different components, group apart. + await run(commands => { commands.add(a, handle, 12); commands.add(b, label, "b2"); commands.add(c, label, "c2"); }); + expect(groups).toEqual([ + { from: [handle, label], to: [handle, label], added: [], removed: [], replaced: [handle], entities: 1 }, + { from: [handle, label], to: [handle, label], added: [], removed: [], replaced: [label], entities: 1 }, + { from: [label], to: [label], added: [], removed: [], replaced: [label], entities: 1 }, + ]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/finalizers.ts b/packages/ecs/src/implementation/world/finalizers.ts new file mode 100644 index 000000000..9e8a5b498 --- /dev/null +++ b/packages/ecs/src/implementation/world/finalizers.ts @@ -0,0 +1,419 @@ +import type { Component } from '../../interface/component/component.js'; +import type { Entity } from '../../interface/entity/entity.js'; +import type { Finalizer } from '../../interface/finalizer/created.js'; +import type { ChangeSet } from '../../interface/finalizer/body.js'; +import type { Commands } from '../../interface/system/commands.js'; +import type { Archetype } from './archetype.js'; +import { createBuffer, valueAt, type CommandBuffer } from './buffer.js'; +import { readCell } from './cells.js'; +import { createCommands } from './commands.js'; +import { asEntity, indexOf, locate } from './entities.js'; +import { createContext, rowBinder, type Context } from './rows.js'; +import { idOf, type Key, type State } from './state.js'; +import type { Queued } from './synchronize.js'; +import type { Essential, ReadonlyValue } from '../../interface/component/value.js'; +import type { Leaf } from '../../interface/finalizer/created.js'; +import { newestKeyOf, threadOf } from '../../interface/registry/index.js'; +import type { TaskChannel } from './remote-systems.js'; +import { writeStops, type StopsOf } from './stop-lists.js'; +import { acquireLookups, refreshUses, type IndexLookups } from './indexes.js'; +import { indexWatchesOf } from './lookup-keys.js'; + +/** A registered finalizer, resolved for a world: what it matches or watches, its context, and the buffer its commands go to. */ +export type FinalizerRunner = { + readonly finalizer: Finalizer; + readonly required: readonly number[]; + readonly excluded: readonly number[]; + /** For a changes finalizer: whether it watches a component, by id. */ + readonly watches: (id: number) => boolean; + /** By archetype id: 1 if it matches, 2 if not, 0 until known. */ + readonly matches: number[]; + readonly context: Context; + /** A new buffer each sync point it issues commands in, since the last one is queued for the next. */ + readonly target: { buffer: CommandBuffer }; + readonly commands: Commands; + readonly reads: readonly Key[]; + /** The indexes its lookups go through, held for the world's life. */ + readonly indexes: IndexLookups; + /** Where a joined thread fills its thread: its stops go to that thread's stop lists, and it runs there. */ + remote: RemoteFinalizer | null; +}; + +/** Before a sync point's finalizers run, or every live match's once the world is disposed: refreshes the indexes they look up through. */ +export const refreshFinalizerIndexes = (state: State): void => { + for (const runner of state.finalizers) if (runner.remote === null && runner.indexes.uses.length > 0) refreshUses(state, runner.indexes.uses); +}; + +/** A finalizer placed on a joined thread: its channel, and what its stop lists hold. */ +export type RemoteFinalizer = StopsOf & { readonly channel: TaskChannel }; + +export const isFinalizer = (value: { readonly kind: string }): value is Finalizer => value.kind === "finalizer" || value.kind === "changes"; + +export const createFinalizerRunner = (state: State, finalizer: Finalizer): FinalizerRunner => { + const { declaration } = finalizer; + const query = finalizer.kind === "finalizer" ? finalizer.declaration.components : undefined; + const reads = query?.read ?? []; + // By default every component that isn't derived, including ones a world first meets later; never resources, nor + // the objects a joined thread fills, whose storage here holds only tags. + let watches = (id: number): boolean => !state.derived[id] && !state.resources.has(id) && state.remote[id] === undefined; + const watch = finalizer.kind === "changes" ? finalizer.declaration.watch : undefined; + if (watch !== undefined) { + // `Finalizer.changes` rejected derived components. + const watched = new Set(watch.map(key => idOf(state, key))); + watches = id => watched.has(id); + } + const target = { buffer: createBuffer() }; + const commands = createCommands(state, target); + // One on another thread than the world's runs there after the sync point, so it issues none, wherever it runs. + const thread = finalizer.kind === "finalizer" ? threadOf(finalizer.declaration, "Finalizer", finalizer.name) : null; + const commanding = thread === null || thread === "world"; + const indexes = acquireLookups(state, declaration.lookup); + // Followed for the world's life, as finalizers are; their ticks start before the next run. + for (const key of indexWatchesOf(declaration.lookup)) state.watchesPending.push(idOf(state, key)); + return { + finalizer, + required: [...new Set([...reads, ...query?.has ?? []])].map(key => idOf(state, key)), + excluded: (query?.not ?? []).map(key => idOf(state, key)), + watches, + matches: [], + context: createContext(state, declaration, commanding ? { commands, indexes: indexes.byName } : { indexes: indexes.byName }), + target, + commands, + reads, + indexes, + remote: null, + }; +}; + +/** + * Places a finalizer on a joined thread's channel, posting it there, or back on the world's thread with null. Its reads + * are listed as the thread reads them: objects by their tags, linear structs by their bytes, strings by handles of + * copies of their records. Throws if the thread can't name it. + */ +export const placeFinalizer = (state: State, runner: FinalizerRunner, channel: TaskChannel | null): void => { + const { finalizer } = runner; + if (channel === null || finalizer.kind !== "finalizer") { + runner.remote = null; + return; + } + const objects: number[] = []; + const linear: number[] = []; + const strides: number[] = []; + const copies: boolean[] = []; + for (const key of runner.reads) { + const id = idOf(state, key); + const layout = state.layouts[id]; + if (layout.kind === "object") objects.push(id); + // A string's stops hold copies of its records, which the thread may read past their entity's sync point. + else if (layout.kind === "linear" || layout.kind === "string") { + linear.push(id); + strides.push(layout.stride); + copies.push(layout.kind === "string"); + } + } + const index = channel.placeFinalizer(state, finalizer); + runner.remote = { finalizer, index, objects, linear, strides, copies, entries: [], channel }; +}; + +const matches = (runner: FinalizerRunner, archetype: Archetype): boolean => { + let known = runner.matches[archetype.id] ?? 0; + if (known === 0) { + const { storageById } = archetype; + known = !archetype.resource && !(archetype.meta && runner.required.length === 0) && runner.required.every(id => storageById[id] !== undefined) && runner.excluded.every(id => storageById[id] === undefined) ? 1 : 2; + runner.matches[archetype.id] = known; + } + return known === 1; +}; + +/** Whether the entity at `entry` was alive before this sync point: neither spawned by a command nor committed from a batch here. */ +const fromOf = (state: State, entry: number): Archetype | null => { + const { plan } = state; + const from = plan.from[entry]; + if (from < 0) return null; + const first = state.born?.get(from); + return first !== undefined && state.entities.row[indexOf(plan.entity[entry])] >= first ? null : state.archetypes[from]; +}; + +type Untyped = { body(row: object, context: object): void }; + +/** What a finalizer threw, kept for after the sync point applies; `"thread"` for one whose thread is gone. */ +export type Thrown = { readonly finalizer: Finalizer; readonly error: unknown; readonly kind?: "thread" }; + +/** Calls `body`, keeping what it throws. */ +const guarded = (errors: Thrown[], finalizer: Finalizer, body: () => void): void => { + try { body(); } + catch (error) { errors.push({ finalizer, error }); } +}; + +/** Whether the entity at `entry` stops matching: it matched before, and is despawned, no longer matches, or had a required component replaced. */ +const stops = (state: State, runner: FinalizerRunner, entry: number): Archetype | null => { + const { plan, archetypes } = state; + const fromId = plan.from[entry]; + if (fromId < 0) return null; + const from = archetypes[fromId]; + if (!matches(runner, from)) return null; + const { born } = state; + if (born !== null && born.size > 0) { + // Committed from a batch here, so it wasn't alive before. + const first = born.get(fromId); + if (first !== undefined && state.entities.row[indexOf(plan.entity[entry])] >= first) return null; + } + if (plan.despawned[entry] === 1 || !matches(runner, archetypes[plan.to[entry]])) return from; + return plan.fresh.size > 0 && (plan.fresh.get(entry)?.some(id => runner.required.includes(id)) ?? false) ? from : null; +}; + +/** A finalizer a joined thread runs: its stops, staged for the thread's stop list. */ +const stageStops = (state: State, runner: FinalizerRunner, remote: RemoteFinalizer): void => { + for (let entry = 0; entry < state.plan.count; entry++) if (stops(state, runner, entry) !== null) remote.entries.push(entry); + if (remote.entries.length > 0) remote.channel.stops.staged.push(remote); +}; + +/** Each entity that stops matching, its body run against its row before apply. */ +const finalizeStops = (state: State, runner: FinalizerRunner, leaf: Leaf, errors: Thrown[]): void => { + // Rows and contexts are built to each finalizer's declaration, so its body accepts them. + const finalizer: Untyped = leaf; + const { plan, entities } = state; + const byArchetype = new Map(); + for (let entry = 0; entry < plan.count; entry++) { + const from = stops(state, runner, entry); + if (from === null) continue; + let entries = byArchetype.get(from); + if (entries === undefined) byArchetype.set(from, entries = []); + entries.push(entry); + } + const bind = rowBinder(state, runner.reads, []); + for (const [archetype, stopped] of byArchetype) { + const row = bind(archetype); + for (const entry of stopped) { + row.index = entities.row[indexOf(plan.entity[entry])]; + guarded(errors, leaf, () => finalizer.body(row.value, runner.context.value)); + } + } +}; + +const missing = Symbol("missing"); + +/** The last value commands give `id` within an entry's ranges, in issue order, or `missing`. */ +export const commandValue = (state: State, queued: readonly Queued[], entry: number, id: number): unknown => { + const { plan } = state; + if (plan.start[entry] < 0) return missing; + const last = (buffer: number, start: number, end: number, found: unknown): unknown => { + const commands = queued[buffer].buffer; + const { ids } = commands; + let at = -1; + for (let k = start; k < end; k++) if (ids[k] === id) at = k; + return at < 0 ? found : valueAt(commands, at, state.layouts); + }; + let value = last(plan.buffer[entry], plan.start[entry], plan.end[entry], missing); + const more = plan.more.size === 0 ? undefined : plan.more.get(entry); + if (more !== undefined) for (let r = 0; r < more.length; r += 3) value = last(more[r], more[r + 1], more[r + 2], value); + return value; +}; + +export const isMissing = (value: unknown): boolean => value === missing; + +/** A watched component's newest key: watched components are registered and not derived, so they're essential. */ +const essential = (state: State, id: number): Essential => newestKeyOf(state.keys[id]) as Essential; + +/** A group of a change set, as it's built. */ +type Grouped = { from: Essential[] | null; to: Essential[] | null; added: Essential[]; removed: Essential[]; replaced: Essential[]; entities: Entity[] }; + +/** No components, shared by every group that replaces none. */ +const none: readonly number[] = []; + +/** The sync point's structural changes to a changes finalizer's components, grouped by how they changed. */ +const changesOf = (state: State, runner: FinalizerRunner, queued: readonly Queued[]): ChangeSet => { + const { plan, archetypes, entities } = state; + const { watches } = runner; + const names = (archetype: Archetype | null): Essential[] | null => + archetype === null ? null : archetype.components.filter(watches).map(id => essential(state, id)); + const groups = new Map(); + /** By `from` and `to` archetype ids: the watched components on each side, and those added and removed. */ + const pairs = new Map(); + const pairOf = (from: Archetype | null, to: Archetype | null) => { + const key = ((from?.id ?? -1) + 1) * 0x200000 + (to?.id ?? -1) + 1; + let pair = pairs.get(key); + if (pair === undefined) { + const before = names(from); + const after = names(to); + pair = { before, after, added: (after ?? []).filter(name => !(before ?? []).includes(name)), removed: (before ?? []).filter(name => !(after ?? []).includes(name)) }; + pairs.set(key, pair); + } + return pair; + }; + /** The watched components an entry's adds give a value that it has before and after, in id order, removed meanwhile or not. */ + const scratch: number[] = []; + const scan = (from: Archetype, to: Archetype, buffer: number, start: number, end: number): void => { + const { ids } = queued[buffer].buffer; + for (let k = start; k < end; k++) { + const id = ids[k]; + if (from.storageById[id] !== undefined && to.storageById[id] !== undefined && watches(id) && !scratch.includes(id)) scratch.push(id); + } + }; + const replacedOf = (entry: number, from: Archetype | null, to: Archetype | null): readonly number[] => { + if (entry < 0 || from === null || to === null || plan.start[entry] < 0) return none; + scratch.length = 0; + scan(from, to, plan.buffer[entry], plan.start[entry], plan.end[entry]); + const more = plan.more.size === 0 ? undefined : plan.more.get(entry); + if (more !== undefined) for (let r = 0; r < more.length; r += 3) scan(from, to, more[r], more[r + 1], more[r + 2]); + return scratch.length === 0 ? none : scratch.slice().sort((a, b) => a - b); + }; + /** + * The last entry's archetypes, the components its one range of values names, none without one, and its group, null + * for no change: consecutive entries, as one system's commands over one archetype make, usually share all three. + */ + let lastFrom: Archetype | null | undefined; + let lastTo: Archetype | null | undefined; + const lastIds: number[] = []; + let lastGroup: Grouped | null = null; + /** Whether an entry's one range of values, or none, names the components the last one's did. */ + const sameIds = (entry: number): boolean => { + const start = entry < 0 ? -1 : plan.start[entry]; + if (start < 0) return lastIds.length === 0; + const { ids } = queued[plan.buffer[entry]].buffer; + const length = plan.end[entry] - start; + if (length !== lastIds.length) return false; + for (let i = 0; i < length; i++) if (ids[start + i] !== lastIds[i]) return false; + return true; + }; + const add = (entity: number, from: Archetype | null, to: Archetype | null, entry: number): void => { + // The resource entity's components are resources, not changes, and reports are the world's own. + if (from?.resource === true || to?.resource === true || from?.meta === true || to?.meta === true) return; + const single = entry < 0 || plan.more.size === 0 || !plan.more.has(entry); + if (single && from === lastFrom && to === lastTo && sameIds(entry)) { + lastGroup?.entities.push(asEntity(entity)); + return; + } + const replaced = replacedOf(entry, from, to); + const { before, after, added, removed } = pairOf(from, to); + let group: Grouped | null = null; + if (replaced.length > 0 || added.length > 0 || removed.length > 0) { + const key = `${from?.id ?? -1}:${to?.id ?? -1}:${replaced.join(",")}`; + group = groups.get(key) ?? null; + if (group === null) groups.set(key, group = { from: before, to: after, added, removed, replaced: replaced.map(id => essential(state, id)), entities: [] }); + group.entities.push(asEntity(entity)); + } + lastFrom = undefined; + if (!single) return; + lastFrom = from; + lastTo = to; + lastGroup = group; + lastIds.length = 0; + const start = entry < 0 ? -1 : plan.start[entry]; + if (start < 0) return; + const { ids } = queued[plan.buffer[entry]].buffer; + for (let k = start; k < plan.end[entry]; k++) lastIds.push(ids[k]); + }; + for (let entry = 0; entry < plan.count; entry++) { + const from = fromOf(state, entry); + const despawned = plan.despawned[entry] === 1; + // Spawned and despawned within the sync point: never there before or after. + if (from === null && despawned) continue; + add(plan.entity[entry], from, despawned ? null : archetypes[plan.to[entry]], entry); + } + // Batch spawns no command touched, which have no entry. + for (const [id, first] of state.born ?? []) { + const archetype = archetypes[id]; + for (let row = first; row < archetype.count; row++) { + const entity = archetype.entities[row]; + if ((plan.slots[indexOf(entity)] ?? -1) < 0) add(entity, null, archetype, -1); + } + } + const live = (entity: Entity, id: number): unknown => { + const index = locate(entities, entity); + if (index < 0) return null; + const storage = archetypes[entities.archetype[index]].storageById[id]; + return storage === undefined ? null : readCell(storage, entities.row[index]); + }; + const bornHere = (entity: Entity): boolean => { + const index = locate(entities, entity); + if (index < 0) return true; + const first = state.born?.get(entities.archetype[index]); + return first !== undefined && entities.row[index] >= first; + }; + const next = (entity: Entity, id: number): unknown => { + const entry = plan.slots[indexOf(entity)] ?? -1; + if (entry < 0 || plan.entity[entry] !== entity) return live(entity, id); + if (plan.despawned[entry] === 1 || archetypes[plan.to[entry]].storageById[id] === undefined) return null; + const value = commandValue(state, queued, entry, id); + return isMissing(value) ? live(entity, id) : value; + }; + return { + groups: [...groups.values()], + // Stored and commanded values match each component's registered schema, so they have its type. + previous: (entity: Entity, key: K) => (bornHere(entity) ? null : live(entity, idOf(state, key))) as ReadonlyValue | null, + next: (entity: Entity, key: K) => next(entity, idOf(state, key)) as ReadonlyValue | null, + }; +}; + +/** Whether a joined thread's finalizers have stops this sync point. */ +const staged = (state: State): boolean => { + for (let k = 0; k < state.channels.length; k++) if (state.channels[k].stops.staged.length > 0) return true; + return false; +}; + +/** + * Runs every finalizer against the planned sync point, before it applies, in their order, and writes the stop lists of + * those joined threads run. Their commands queue for the next sync point. Returns the errors they threw, for after + * the sync point applies. + */ +export const runFinalizers = (state: State, queued: readonly Queued[], born: Map, tick: number): Thrown[] => { + const errors: Thrown[] = []; + state.born = born; + try { + for (const runner of state.finalizers) { + const { finalizer, remote } = runner; + if (remote !== null) { + stageStops(state, runner, remote); + continue; + } + runner.context.bind(); + if (finalizer.kind === "finalizer") finalizeStops(state, runner, finalizer, errors); + else { + // The context is built to the finalizer's declaration, so its body accepts it. + const changes: { body(changes: ChangeSet, context: object): void } = finalizer; + guarded(errors, finalizer, () => changes.body(changesOf(state, runner, queued), runner.context.value)); + } + if (runner.target.buffer.count > 0) { + state.queued.push({ buffer: runner.target.buffer, guarded: true }); + runner.target.buffer = createBuffer(); + } + } + if (state.claimed && staged(state)) { + const { plan } = state; + const source = { entity: plan.entity, from: plan.from, despawned: plan.despawned, archetypes: state.archetypes, row: state.entities.row, holds: state.holds }; + for (const channel of state.channels) writeStops(channel.stops, tick, source); + } + } + finally { + state.born = null; + // Left clean after a throw too, for the next sync point. + for (const channel of state.channels) { + for (const stops of channel.stops.staged) stops.entries.length = 0; + channel.stops.staged.length = 0; + } + } + return errors; +}; + +/** At disposal: every per-query finalizer runs for every live entity it matches, so the handles they release are. */ +export const finalizeAll = (state: State): Thrown[] => { + const errors: Thrown[] = []; + for (const runner of state.finalizers) { + const { finalizer: leaf } = runner; + // A joined thread finalizes its own, once it hears the world is disposed. + if (leaf.kind !== "finalizer" || runner.remote !== null) continue; + const finalizer: Untyped = leaf; + runner.context.bind(); + const bind = rowBinder(state, runner.reads, []); + for (const archetype of state.archetypes) { + if (archetype.count === 0 || !matches(runner, archetype)) continue; + const row = bind(archetype); + for (let index = 0; index < archetype.count; index++) { + row.index = index; + guarded(errors, leaf, () => finalizer.body(row.value, runner.context.value)); + } + } + } + return errors; +}; diff --git a/packages/ecs/src/implementation/world/fixtures.test-helper.ts b/packages/ecs/src/implementation/world/fixtures.test-helper.ts new file mode 100644 index 000000000..90f041e13 --- /dev/null +++ b/packages/ecs/src/implementation/world/fixtures.test-helper.ts @@ -0,0 +1,19 @@ +/// +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { inject } from "vitest"; + +declare module "vitest" { + export interface ProvidedContext { + /** This run's built fixtures, which `vitest.fixtures.ts` provides. */ + readonly fixturesDirectory: string; + } +} + +/** + * A fixture `vitest.fixtures.ts` built for this run, `.fixture.js`, imported natively, so its workers load + * `worker.js` beside it. Its exports are the source fixture's, since it's built from it, so callers give the result the + * source's type. + */ +export const builtFixture = (name: string) => + import(/* @vite-ignore */ pathToFileURL(resolve(inject("fixturesDirectory"), `${name}.fixture.js`)).href); diff --git a/packages/ecs/src/implementation/world/generated-code.md b/packages/ecs/src/implementation/world/generated-code.md new file mode 100644 index 000000000..bf079d670 --- /dev/null +++ b/packages/ecs/src/implementation/world/generated-code.md @@ -0,0 +1,140 @@ +# Generated kernel code + +What one kernel system compiles to on each backend. Calls are inlined into the Program IR, and JS, WASM and WGSL are each +generated from it. A GPU kernel's calls to math functions that declare a WGSL builtin become regions: WGSL emits the +builtin, and JS and WASM run the inlined body. + +## Regenerating + +1. Save the test below as `src/scratch/show.test.ts`, with your system in `source`. The column layout is derived from + the description, for components of three f32 leaves; adjust `columns` for other components. +2. Run `npx vitest run src/scratch/show.test.ts`. It prints the rewritten module (with the description and program), the + JS kernel module and the WGSL, and writes the WASM to `/tmp/kernel.wasm`. +3. To read the WASM: `npm install wabt` in a scratch directory, then `node_modules/wabt/bin/wasm2wat --enable-all /tmp/kernel.wasm`. +4. Delete `src/scratch`. + +```ts +import { test } from 'vitest'; +import { parseAst } from 'vite'; +import { existsSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { transformKernels } from '../implementation/compiler/transform.js'; +import { createLinker } from '../implementation/compiler/link.js'; +import { packageIndex } from '../implementation/compiler/paths.js'; +import { estreeOf } from '../implementation/compiler/estree.js'; +import { generateWgsl } from '../implementation/world/wgsl.js'; +import { generateWasm, type WasmColumn } from '../implementation/world/wasm.js'; + +const parse = (code: string) => estreeOf(parseAst(code)); + +test("show", async () => { + const source = `import { System, Vec3 } from "@adobe/ecs"; +const position = "position"; const velocity = "velocity"; +const move = System.create({ name: "move", kernel: "gpu", components: { read: [velocity], write: [position] } }, + row => { row[position] = Vec3.add(row[position], row[velocity]); });`; + // Resolves the package to its source and relative imports to files, stripping types as the plugin does. + const linker = createLinker({ + resolve: async (specifier, importer) => { + if (specifier === "@adobe/ecs") return packageIndex; + const path = resolve(dirname(importer), specifier); + return existsSync(path.replace(/\.js$/, ".ts")) ? path.replace(/\.js$/, ".ts") : path; + }, + load: async file => { + const code = await readFile(file, "utf8"); + return { code: file.endsWith(".ts") ? (await (await import("vite")).transformWithOxc(code, file, { lang: "ts" })).code : code }; + }, + }, parse); + const result = await transformKernels(source, "/move.js", parse, async s => s === "@adobe/ecs", i => JSON.stringify(`kernel:${i}`), linker); + if (result === null || "failure" in result) throw new Error(JSON.stringify(result)); + console.log(result.code); + console.log(result.kernels[0]); + // The description, with component keys bound to their names. + const text = result.code.slice(result.code.indexOf("attachKernel$(") + 14); + const { columns: described, program } = new Function("position", "velocity", `return ${text.slice(text.indexOf("}), {") + 3, text.lastIndexOf("});") + 1)};`)("position", "velocity"); + // Each component is three f32 leaves of one row; tick columns are f64. + const columns: WasmColumn[] = described.map((column: { kind: string; component: string; path: number[] }) => column.kind === "field" + ? { view: "f32", stride: 12, offset: column.path[0] * 4, boolean: false, component: column.component === "position" ? 0 : 1 } + : { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }); + const wgsl = generateWgsl(program, columns, () => "number"); + console.log(typeof wgsl === "string" ? wgsl : wgsl.code); + const wasm = generateWasm(program, columns, () => "number", true); + if (wasm !== null) writeFileSync("/tmp/kernel.wasm", wasm.bytes); +}); +``` + +## Example: `row => { row[position] = Vec3.add(row[position], row[velocity]); }` + +`position` and `velocity` are `Vec3` components, each 12 bytes per row, in a `kernel: "gpu"` system. + +### JS (the row loop of the kernel module) + +Local names come from `Vec3.add`'s own parameters. + +```js +rows: for (let i = start, x0 = start * s0 + o0, /* … x6 */; i < end; i++, x0 += s0, /* … */) { +let x1_0 = c0[x0]; +let y1_1 = c1[x1]; +let z1_2 = c2[x2]; +let x2_3 = c3[x3]; +let y2_4 = c4[x4]; +let z2_5 = c5[x5]; +c0[x0] = (x1_0 + x2_3); +c1[x1] = (y1_1 + y2_4); +c2[x2] = (z1_2 + z2_5); +c6[x6] = tick; +} +``` + +### WGSL + +`Vec3.add` declares WGSL's `+` on `vec3f`, so the shader adds vectors; its result is named after the function. The +shader also carries a short preamble (`toUint32`, `power`, `roundHalfUp`) for operators whose JS results differ from +WGSL's on finite values; it's omitted here. + +```wgsl +fn kernelRow(row: u32) { + var add_6: vec3f; + add_6 = (vec3f(bitcast(memory[parameters[1] + row * 3u + 0u]), bitcast(memory[parameters[2] + row * 3u + 1u]), bitcast(memory[parameters[3] + row * 3u + 2u])) + vec3f(bitcast(memory[parameters[4] + row * 3u + 0u]), bitcast(memory[parameters[5] + row * 3u + 1u]), bitcast(memory[parameters[6] + row * 3u + 2u]))); + memory[parameters[1] + row * 3u + 0u] = bitcast(add_6.x); + memory[parameters[2] + row * 3u + 1u] = bitcast(add_6.y); + memory[parameters[3] + row * 3u + 2u] = bitcast(add_6.z); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) groups: vec3u) { + let row = id.x + id.y * groups.x * 64u; + if (row >= parameters[0]) { return; } + kernelRow(row); +} +``` + +Calls nest as builtins do: `Vec3.scale(Vec3.normalize(Vec3.cross(p, v)), Vec3.dot(p, v))` emits +`cross_6 = cross(…)`, `normalize_19 = normalize(cross_6)`, `dot_26 = dot(…)` and `scale_31 = (normalize_19 * dot_26)`. + +### WASM (499 bytes, excerpts from `wasm2wat`) + +Three loops: chunk stamps, four rows at a time, then one row at a time for the rest. + +```wat +;; Four rows: 48 bytes per component, three v128 loads each. +local.get 4 … v128.load align=4 ;; position +local.get 4 … v128.load offset=16 align=4 +local.get 4 … v128.load offset=32 align=4 +local.get 5 … v128.load align=4 ;; velocity, likewise +… +local.get 22 local.get 25 f32x4.add … v128.store align=4 +local.get 23 local.get 26 f32x4.add … v128.store offset=16 align=4 +local.get 24 local.get 27 f32x4.add … v128.store offset=32 align=4 +local.get 2 f64x2.splat v128.store align=8 ;; changed ticks, two rows per store + +;; One row: x, y and z as one f32x4. +v128.load align=4 ;; position +v128.load align=4 ;; velocity +f32x4.add +v128.store64_lane align=4 0 ;; x, y +v128.store32_lane offset=8 2 ;; z +``` + +The one-row loads read 16 bytes of a 12-byte element; columns keep `columnSlack` bytes after their last row for that. +The stores write only the element's 12 bytes. diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts new file mode 100644 index 000000000..bc575fe77 --- /dev/null +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -0,0 +1,693 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Gpu } from "../../interface/gpu/gpu.js"; +import { Mat4x4, Quat, Vec3 } from "../../interface/math/index.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { System } from "../../interface/system/system.js"; +import { read, readEach, readEntity, readResource, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { Observe } from "../../observe/index.js"; +import { runReported } from "./reports.test-helper.js"; + +const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; +const position = Symbol("gpuTest/position"); +const velocity = Symbol("gpuTest/velocity"); +const score = Symbol("gpuTest/score"); +const tag = Symbol("gpuTest/tag"); +const code = Symbol("gpuTest/code"); +const amount = Symbol("gpuTest/amount"); +const flag = Symbol("gpuTest/flag"); +const followed = Symbol("gpuTest/followed"); +const bits = Symbol("gpuTest/bits"); +const sums = Symbol("gpuTest/sums"); +const ownedBy = Symbol("gpuTest/ownedBy"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number, number] }; + [velocity]: { type: readonly [number, number, number] }; + [score]: { type: number }; + [tag]: { type: true }; + [code]: { type: number }; + [amount]: { type: number }; + [flag]: { type: boolean }; + [followed]: { type: Entity }; + [bits]: { type: Vec3.U32 }; + [sums]: { type: Vec3.I32 }; + [ownedBy]: { type: Entity; reference: "owner" }; + } +} + +Component.register(position, { type: vec3 }); +Component.register(velocity, { type: vec3 }); +Component.register(score, { type: { type: "number", precision: 1 } }); +Component.register(tag, { type: { const: true } }); +Component.register(code, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(amount, { type: { type: "number", precision: 1 } }); +Component.register(flag, { type: { type: "boolean" } }); +Component.register(followed, { type: Entity.schema }); +Component.register(ownedBy, { type: Entity.schema, reference: "owner" }); + +const count = 5000; + +const spawn = System.create({ name: "gpuTest/spawn" }, ({ commands }) => { + for (let i = 0; i < count; i++) { + if (i % 2 === 0) commands.spawn([position, velocity])({ [position]: [i, 0, -i], [velocity]: [1, i % 7, 0.5] }); + else commands.spawn([position, velocity, tag])({ [position]: [i, 0, -i], [velocity]: [1, i % 7, 0.5], [tag]: true }); + } +}); + +const move = System.create({ name: "gpuTest/move", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { + const [px, py, pz] = row[position]; + const [vx, vy, vz] = row[velocity]; + row[position] = [px + vx, py + vy * 2, pz + vz]; +}); + +/** Between two GPU kernels: reads what the first wrote, and changes what both read. */ +const reset = System.create({ name: "gpuTest/reset", components: { write: [position, velocity] }, after: [move] }, row => { + const [px, py, pz] = row[position]; + const [vx, vy, vz] = row[velocity]; + row[position] = [px * 2, py, pz + 100]; + row[velocity] = [vz, vx, vy]; +}); + +const moveAgain = System.create({ name: "gpuTest/moveAgain", kernel: "gpu", components: { read: [velocity], write: [position] }, after: [reset] }, row => { + const [px, py, pz] = row[position]; + const [vx, vy, vz] = row[velocity]; + row[position] = [px + vx, py + vy, pz + vz]; +}); + +/** Between two GPU kernels: changes only what they read, so nothing needs reading back first. */ +const turn = System.create({ name: "gpuTest/turn", components: { write: [velocity] }, after: [move] }, row => { + const [vx, vy, vz] = row[velocity]; + row[velocity] = [vz * 3, vx, vy]; +}); + +const moveLater = System.create({ name: "gpuTest/moveLater", kernel: "gpu", components: { read: [velocity], write: [position] }, after: [turn] }, row => { + const [px, py, pz] = row[position]; + const [vx, vy, vz] = row[velocity]; + row[position] = [px + vx, py + vy, pz + vz]; +}); + +const turning = System.create({ name: "gpuTest/turning", systems: [move, turn, moveLater] }); + +const spawnNumbers = System.create({ name: "gpuTest/spawnNumbers" }, ({ commands }) => { + for (const [c, a] of [[0, 0], [3, -2.5], [0xff00ff00, 2.5], [0xffffff00, 7], [0x80000000, -0.25]]) commands.spawn([code, amount])({ [code]: c, [amount]: a }); +}); + +/** + * JS semantics the GPU keeps: u32 values past 2^31, integer and bitwise chains bit for bit, infinities, rounding. + * Arithmetic is f32, so only results f32 holds exactly are compared; WGSL lets implementations assume no value is NaN. + */ +const numbers = System.create({ name: "gpuTest/numbers", kernel: "gpu", components: { write: [code, amount] } }, row => { + const c = row[code]; + const a = row[amount]; + const mixed = (c ^ 0x0f0f0f0f) >>> 0; + row[code] = mixed > 0x80000000 ? mixed : (c & 0xff00ff00) >>> 0; + row[amount] = Math.min(a, Infinity) + Math.round(a) + (-Infinity < a ? 1000 : 0) + (~c & 0xff) + ~(c >>> 28) + (c >>> 24) + + (c | 0) / 0x1000000 + Math.clz32(c) + (c > 0x80000000 ? 10000 : 0) + Math.max(a, -1) * 100 + (a % 1.5) * 10000; +}); + +const flagScore = System.create({ name: "gpuTest/flagScore", kernel: "gpu", components: { read: [flag], write: [score] } }, row => { + row[score] = row[flag] ? 1 : 0; +}); + +const spawnFollower = System.create({ name: "gpuTest/spawnFollower" }, ({ commands }) => { + const leader = commands.spawn([position, velocity])({ [position]: [10, 0, 0], [velocity]: [5, 0, 0] }); + commands.spawn([followed, score])({ [followed]: leader, [score]: 0 }); +}); + +/** On the CPU, reads through a lookup what a GPU kernel wrote. */ +const follow = System.create({ + name: "gpuTest/follow", + components: { read: [followed], write: [score] }, + lookup: { leader: { read: [position] } }, + after: [move], +}, (row, { lookup: { leader } }) => { + row[score] = leader(row[followed])?.[position][0] ?? -1; +}); + +const following = System.create({ name: "gpuTest/following", systems: [move, follow] }); + +/** Despawns the first entity, moving the last into its row, then makes the sync point throw. */ +const poison = System.create({ name: "gpuTest/poison", components: { read: [position] } }, (row, { commands }) => { + if (row[position][0] === 2) commands.despawn(row.entity); + if (row[position][0] === 4) commands.add(row.entity, score, Symbol("poison") as unknown as number); +}); + +let frameNumber = 0; +const spawnedNow: Entity[] = []; + +/** Spawns a few particles each frame, through growth of their archetypes; nothing on the CPU reads their positions. */ +const spawnFew = System.create({ name: "gpuTest/churn" }, ({ commands }) => { + for (let i = 0; i < 37; i++) spawnedNow.push(commands.spawn([position, velocity])({ [position]: [frameNumber, i, 0], [velocity]: [1, i % 3, 0.25] })); + // Every fifth frame, a despawn moves another particle into a row the GPU wrote. + if (frameNumber % 5 === 4) commands.despawn(spawnedNow[frameNumber * 3]); +}); + +/** A render body that fails after starting its pass. */ +const fail = System.render({ name: "gpuTest/fail", shader: { accessors: { place: position } }, after: [move] }, ({ encoder }) => { + encoder.beginComputePass(); + throw new Error("draw failed"); +}); + +const failing = System.create({ name: "gpuTest/failing", systems: [move, fail] }); + +/** Math from three namespaces, inlined into a compute shader. */ +const transform = System.create({ name: "gpuTest/transform", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { + const q = Quat.fromAxisAngle([0, 1, 0], 0.5); + const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); + row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); +}); + +Component.register(bits, { type: Vec3.U32.schema }); +Component.register(sums, { type: Vec3.I32.schema }); + +const spawnIntegers = System.create({ name: "gpuTest/spawnIntegers" }, ({ commands }) => { + for (let i = 0; i < 100; i++) { + commands.spawn([bits, sums])({ [bits]: [Math.imul(i + 1, 0x9E3779B9) >>> 0, (0xFFFFFFFF - i) >>> 0, (i << 26) >>> 0], [sums]: [2 ** 31 - 1 - i, -(2 ** 31) + i, Math.imul(i, 65536)] }); + } +}); + +/** Integer builtins, which wrap and count bits as the functions do. */ +const integers = System.create({ name: "gpuTest/integers", kernel: "gpu", components: { write: [bits, sums] } }, row => { + const b = row[bits]; + const s = row[sums]; + row[bits] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), Vec3.U32.bitwiseXor(b, [7, 7, 7])))); + row[sums] = Vec3.I32.add(Vec3.I32.multiply(s, [65536, 3, 65536]), Vec3.I32.abs(Vec3.I32.negate(s))); +}); + +/** Float builtins across vectors, quaternions and matrices, on in-domain inputs. */ +const shading = System.create({ name: "gpuTest/shading", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { + const p = row[position]; + const v = Vec3.add(row[velocity], [0.25, 0.5, 0.75]); + const n = Vec3.normalize(v); + const lit = Vec3.smoothstep([0, 0, 0], [4, 4, 4], Vec3.abs(p)); + const bent = Vec3.add(Vec3.reflect(n, [0, 1, 0]), Vec3.faceforward(n, [1, 0, 0], [0, 0, 1])); + const mixed = Vec3.mix(Vec3.round(Vec3.scale(p, 0.5)), Vec3.fract(p), 0.25); + const clamped = Vec3.clamp(Vec3.add(Vec3.min(p, v), Vec3.max(p, v)), [-2, -2, -2], [2, 2, 2]); + const m = Mat4x4.transpose(Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.rotationY(0.5))); + const [x, y, z] = Mat4x4.multiplyVec4(m, [p[0], p[1], p[2], 1]); + const q = Quat.normalize([v[0], v[1], v[2], 1]); + const weight = Vec3.distance(p, v) * 0.01 + Vec3.length(lit) + Quat.dot(q, q) + Mat4x4.determinant(m) + Vec3.step([0, 0, 0], p)[0]; + row[position] = Vec3.add(Vec3.add(Vec3.add(lit, bent), Vec3.add(mixed, clamped)), Vec3.scale([x, y, z], weight)); +}); + +/** Reads the entity, an f64. */ +const entityScore = System.create({ name: "gpuTest/entityScore", kernel: "gpu", components: { write: [score] } }, row => { + row[score] = row.entity; +}); + +let watched = 0; +const watch = System.create({ name: "gpuTest/watch", components: { changed: [position] } }, () => { watched++; }); + +const frame = System.create({ name: "gpuTest/frame", systems: [move, reset, moveAgain] }); + +/** Added while others come and go, so ticks go on. */ +const steady = System.create({ name: "gpuTest/steady" }, () => {}); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +const positions = async (world: ReturnType): Promise => + (await readEach(world, await select(world, { required: [position] }), position)).map(value => value ?? [NaN, NaN, NaN]); + +let nextDevice: GPUDevice | null = null; +const setDevice = System.create({ name: "gpuTest/setDevice", resources: { write: [gpuDevice] } }, ({ resources }) => { resources[gpuDevice] = nextDevice; }); + +describe("removing GPU systems", () => { + it("releases the GPU records of a render system and a GPU kernel once they're removed", async () => { + const world = createWorld({ [workers]: 0, [always]: true, [gpuDevice]: await device() }); + await world.run(spawn); + let drawn = 0; + const draw = System.render({ name: "gpuTest/drawUntilRemoved", shader: { accessors: { place: position } }, after: [move] }, () => { drawn++; }); + const ticks = async (count: number): Promise => { + const from = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await new Promise(resolve => { + const stop = Observe.resource(world, clock)(({ tick }) => { + if (tick < from + count) return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + }; + world.add(steady); + await ticks(2); + const before = await world.tables(); + world.add(draw); + world.add(move); + await ticks(3); + expect((await world.tables()).gpu).toBeGreaterThan(before.gpu); + world.remove(draw); + world.remove(move); + const removed = drawn; + await ticks(3); + expect(drawn).toBe(removed); + expect((await world.tables()).gpu).toBe(before.gpu); + await world.dispose(); + }); +}); + +describe("the gpuDevice resource", () => { + it("moves GPU kernels onto the device once a system sets it", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(move); + // On the CPU, reads see the write at once. + const moved = (await positions(world))[0]; + nextDevice = await device(); + await world.run(setDevice); + await world.run(move); + // On the GPU, reads read back its write first. + expect((await positions(world))[0]).not.toEqual(moved); + await world.dispose(); + }); + + it("sets itself to null once the device is lost, so the world goes on without it until a new one is set", async () => { + const lost = await device(); + const world = createWorld({ [workers]: 0, [gpuDevice]: lost }); + const cpuWorld = createWorld({ [workers]: 0 }); + let changes = 0; + const watchDevice = System.create({ name: "gpuTest/watchDevice", resources: { read: [gpuDevice], changed: [gpuDevice] } }, ({ resources }) => { + void resources[gpuDevice]; + changes++; + }); + let drawn = 0; + const draw = System.render({ name: "gpuTest/drawWhileLost", shader: { accessors: { place: position } } }, () => { drawn++; }); + for (const each of [world, cpuWorld]) await each.run(spawn); + await world.run(watchDevice); + // This move stays on the lost device, so it's lost with it. + await world.run(move); + lost.destroy(); + await lost.lost; + // Nothing fails: the move runs on the CPU from the CPU's copies, and the render system is skipped. + await world.run(move); + await world.run(draw); + expect(drawn).toBe(0); + expect(await readResource(world, gpuDevice)).toBeNull(); + await world.run(watchDevice); + expect(changes).toBe(2); + nextDevice = await device(); + await world.run(setDevice); + await world.run(move); + await world.run(draw); + expect(drawn).toBe(1); + for (let i = 0; i < 2; i++) await cpuWorld.run(move); + expect(await positions(world)).toEqual(await positions(cpuWorld)); + await world.dispose(); + await cpuWorld.dispose(); + }); + + it("moves to another device, reading back what's ahead on the one it leaves", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const each of [world, cpuWorld]) await each.run(spawn); + await world.run(move); + nextDevice = await device(); + await world.run(setDevice); + await world.run(move); + for (let i = 0; i < 2; i++) await cpuWorld.run(move); + expect(await positions(world)).toEqual(await positions(cpuWorld)); + await world.dispose(); + await cpuWorld.dispose(); + }); + + it("runs GPU kernels on the CPU again when set to null, and skips render systems", async () => { + const world = createWorld({ [workers]: 2, [gpuDevice]: await device() }, { minimumChunk: 1 }); + const cpuWorld = createWorld({ [workers]: 0 }); + let drawn = 0; + const draw = System.render({ name: "gpuTest/drawUntilNull", shader: { accessors: { place: position } } }, () => { drawn++; }); + for (const each of [world, cpuWorld]) await each.run(spawn); + await world.run(draw); + await world.run(move); + nextDevice = null; + await world.run(setDevice); + await world.run(move); + for (let i = 0; i < 2; i++) await cpuWorld.run(move); + // On the CPU, reads see writes at once. + expect(await positions(world)).toEqual(await positions(cpuWorld)); + await world.run(draw); + expect(drawn).toBe(1); + await world.dispose(); + await cpuWorld.dispose(); + }); +}); + +describe("GPU kernels", () => { + // Each comparison runs the same GPU kernels in a world without a GPU, where they run on the CPU. + it("write values reads see, as the CPU kernel's do", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + for (let i = 0; i < 3; i++) await world.run(move); + } + // Reads read back what the GPU wrote first. + expect((await positions(gpuWorld))[0]).not.toEqual([0, 0, -0]); + expect(await positions(gpuWorld)).toEqual(await positions(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("see main-thread writes made between them in the same run, and the main thread sees theirs", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + await world.run(frame); + await world.run(frame); + } + expect(await positions(gpuWorld)).toEqual(await positions(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("see main-thread writes to what they only read, made between them in the same run", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + await world.run(turning); + } + expect(await positions(gpuWorld)).toEqual(await positions(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("keep their writes when the sync point moves rows of their archetypes", async () => { + const spawnOne = System.create({ name: "gpuTest/spawnOne" }, ({ commands }) => { + commands.spawn([position, velocity])({ [position]: [-1, -1, -1], [velocity]: [0, 0, 0] }); + }); + const world = createWorld({ [gpuDevice]: await device() }); + await world.run(spawn); + await world.run(move); + await world.run(spawnOne); + await world.run(move); + const all = await positions(world); + expect(all.length).toBe(count + 1); + expect(all.filter(([x]) => x === -1)).toEqual([[-1, -1, -1]]); + // Moved twice: once before the spawn's sync point and once after. + expect(all.filter(([x]) => x === 2)).toEqual([[2, 0, 1]]); + await world.dispose(); + }); + + it("keep their writes when a cascade moves rows of their archetypes", async () => { + const owners: Entity[] = []; + const spawnOwned = System.create({ name: "gpuTest/spawnOwned" }, ({ commands }) => { + owners.push(commands.spawn([score])({ [score]: 0 }), commands.spawn([score])({ [score]: 1 })); + for (let i = 0; i < 64; i++) commands.spawn([position, velocity, ownedBy])({ [position]: [i, 0, 0], [velocity]: [1, 0, 0], [ownedBy]: owners[i === 0 ? 0 : 1] }); + }); + // No command touches the owned rows: despawning their owner removes them. + const despawnOwner = System.create({ name: "gpuTest/despawnOwner" }, ({ commands }) => { + const [owner] = owners; + if (owner !== undefined) commands.despawn(owner); + }); + const world = createWorld({ [gpuDevice]: await device() }); + await world.run(spawnOwned); + await world.run(move); + await world.run(despawnOwner); + const xs = (await positions(world)).map(([x]) => x).sort((a, b) => a - b); + expect(xs).toEqual(Array.from({ length: 63 }, (_, i) => i + 2)); + await world.dispose(); + }); + + it("keep JS semantics for u32 values, bitwise operators and infinities", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + const values = async (world: ReturnType): Promise => + Promise.all((await select(world, { required: [code] })).map(entity => readEntity(world, entity, [code, amount]))); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawnNumbers); + await world.run(numbers); + } + expect(await values(gpuWorld)).toEqual(await values(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("are read back for CPU systems that look them up", async () => { + const world = createWorld({ [gpuDevice]: await device() }); + await world.run(spawnFollower); + await world.run(following); + await world.run(following); + const [follower] = await select(world, { required: [followed] }); + expect(await read(world, follower, score)).toBe(20); + await world.dispose(); + }); + + it("keep their copies current when a sync point fails partway", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + await world.run(move); + await expect(world.run(poison)).rejects.toThrow(); + await world.run(move); + } + expect(await positions(gpuWorld)).toEqual(await positions(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("keep their writes as spawns grow their archetypes and despawns move rows", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + spawnedNow.length = 0; + for (frameNumber = 0; frameNumber < 40; frameNumber++) { + await world.run(spawnFew); + await world.run(move); + } + } + expect((await positions(gpuWorld)).length).toBe((await positions(cpuWorld)).length); + expect(await positions(gpuWorld)).toEqual(await positions(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("pass change filters on what they write, once per write", async () => { + const counts: number[][] = []; + for (const world of [createWorld({ [gpuDevice]: await device() }), createWorld({ [workers]: 0 })]) { + watched = 0; + const seen: number[] = []; + await world.run(spawn); + for (const system of [watch, watch, move, watch, watch, move, move, watch]) { + await world.run(system); + seen.push(watched); + } + counts.push(seen); + await world.dispose(); + } + expect(counts[0]).toEqual(counts[1]); + expect(counts[0]).toEqual([count, count, count, 2 * count, 2 * count, 2 * count, 2 * count, 3 * count]); + }); + + it("inline math functions as the CPU does, at f32 precision", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + await world.run(transform); + } + const onGpu = (await positions(gpuWorld)).flat(); + const cpu = (await positions(cpuWorld)).flat(); + expect(onGpu.length).toBe(cpu.length); + const worst = Math.max(...onGpu.map((value, i) => Math.abs(value - cpu[i]) / Math.max(1, Math.abs(cpu[i])))); + expect(worst).toBeLessThan(1e-5); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("emit WGSL builtins for integer math, which wraps and counts bits as the CPU does", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawnIntegers); + await world.run(integers); + } + const values = async (world: ReturnType): Promise => + Promise.all((await select(world, { required: [bits] })).map(entity => readEntity(world, entity, [bits, sums]))); + expect(await values(gpuWorld)).toEqual(await values(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("emit WGSL builtins for float math, within f32 precision of the CPU", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + await world.run(shading); + } + const onGpu = (await positions(gpuWorld)).flat(); + const cpu = (await positions(cpuWorld)).flat(); + expect(onGpu.length).toBe(cpu.length); + const worst = Math.max(...onGpu.map((value, i) => Math.abs(value - cpu[i]) / Math.max(1, Math.abs(cpu[i])))); + expect(worst).toBeLessThan(1e-4); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); + + it("refuse what they can't run, with the reason", async () => { + const world = createWorld({ [gpuDevice]: await device() }); + await expect(world.run(entityScore)).rejects.toThrow(/can't run on the GPU: it reads an f64 value/); + await expect(world.run(flagScore)).rejects.toThrow(/reads a byte value/); + // In a world without a GPU too: the declaration can never hold. + const cpuWorld = createWorld({ [workers]: 0 }); + await expect(cpuWorld.run(flagScore)).rejects.toThrow(/reads a byte value/); + await cpuWorld.dispose(); + await world.dispose(); + }); +}); + +describe("render systems", () => { + it("that fail lose only their own work", async () => { + const world = createWorld({ [gpuDevice]: await device() }); + await world.run(spawn); + expect((await runReported(world, failing)).failures.map(failure => failure.message)).toEqual(["draw failed"]); + expect((await positions(world))[0]).toEqual([1, 0, 0.5]); + await world.dispose(); + }); + + it("that record invalid work make the next run report the GPU's error, under the run that submitted it", async () => { + // A dispatch without a pipeline is a validation error when the encoder finishes. + const invalid = System.render({ name: "gpuTest/invalid", shader: { accessors: { place: position } } }, ({ encoder }) => { + const pass = encoder.beginComputePass(); + pass.dispatchWorkgroups(1); + pass.end(); + }); + const own = await device(); + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + await world.run(invalid); + // The device reports the error once it has processed the run's work. + await own.queue.onSubmittedWorkDone(); + const [failure] = (await runReported(world, spawn)).failures; + expect([failure.kind, failure.name]).toEqual(["gpu", "gpuTest/invalid"]); + expect(failure.message).toMatch(/^validation: /); + expect((await runReported(world, spawn)).failures).toEqual([]); + await world.dispose(); + }); + + it("name accessors anything WGSL allows, and only that", async () => { + const own = await device(); + const named = (name: "gpuTest/count" | "gpuTest/target", accessor: string) => System.render({ name, shader: { accessors: { [accessor]: position } } }, () => {}); + const module = own.createShaderModule({ code: `${Gpu.wgsl(named("gpuTest/count", "count"))}\n@compute @workgroup_size(1) fn main() { _ = count(0u); }` }); + expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); + expect(() => Gpu.wgsl(named("gpuTest/target", "target"))).toThrow(/isn't usable as a WGSL function name/); + }); + + it("read their components on the GPU through the generated accessors", async () => { + const own = await device(); + const output = own.createBuffer({ size: count * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); + const copy = System.render({ + name: "gpuTest/copy/2", + components: { has: [tag] }, + shader: { group: 0, accessors: { place: position } }, + after: [move], + }, ({ encoder, tables }) => { + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); + pass.setBindGroup(1, outputGroup); + for (const table of tables) { + table.bind(pass); + pass.dispatchWorkgroups(Math.ceil(table.count / 64)); + } + pass.end(); + }); + const world = createWorld({ [gpuDevice]: own }); + const outputLayout = own.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }] }); + const outputGroup = own.createBindGroup({ layout: outputLayout, entries: [{ binding: 0, resource: { buffer: output } }] }); + const module = own.createShaderModule({ + code: `${Gpu.wgsl(copy)} +@group(1) @binding(0) var output: array; +@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3u) { + if (id.x >= ecs_count()) { return; } + output[id.x] = vec4f(place(id.x), 1.0); +}`, + }); + const pipeline = own.createComputePipeline({ layout: own.createPipelineLayout({ bindGroupLayouts: [Gpu.layout(own), outputLayout] }), compute: { module, entryPoint: "main" } }); + const frameWithCopy = System.create({ name: "gpuTest/frame/2", systems: [move, copy] }); + await world.run(spawn); + await world.run(frameWithCopy); + const staging = own.createBuffer({ size: output.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + const encoder = own.createCommandEncoder(); + encoder.copyBufferToBuffer(output, 0, staging, 0, output.size); + own.queue.submit([encoder.finish()]); + await staging.mapAsync(GPUMapMode.READ); + const copied = new Float32Array(staging.getMappedRange()); + // The tagged entities are the odd ones, in spawn order, each moved once. + const expected = Array.from({ length: count / 2 }, (_, k) => { + const i = 2 * k + 1; + return [i + 1, (i % 7) * 2, -i + 0.5, 1]; + }); + expect(Array.from({ length: count / 2 }, (_, k) => Array.from(copied.subarray(k * 4, k * 4 + 4)))).toEqual(expected); + await world.dispose(); + }); +}); + +/** The x a spawned position reaches after one `move`, of the one row the nudges below write. */ +const nudged = 3000; + +const nudgeRow = System.create({ name: "gpuTest/nudgeRow", components: { write: [position] } }, row => { + const [px, py, pz] = row[position]; + if (px === nudged) row[position] = [px, py + 1, pz]; +}); + +const nudgeKernel = System.create({ name: "gpuTest/nudgeKernel", kernel: "cpu", components: { write: [position] } }, row => { + const [px, py, pz] = row[position]; + if (px === nudged) row[position] = [px, py + 1, pz]; +}); + +/** The sizes of the row uploads a device's queue writes from now on: record writes pass no size. */ +const uploadSizes = (own: GPUDevice): number[] => { + const sizes: number[] = []; + const { queue } = own; + const write = queue.writeBuffer.bind(queue); + queue.writeBuffer = (buffer, offset, data, dataOffset, size) => { + if (size !== undefined) sizes.push(size); + write(buffer, offset, data, dataOffset, size); + }; + return sizes; +}; + +describe("CPU writes to GPU copies", () => { + const cases = [ + { name: "a row system", nudge: nudgeRow, threads: 0, wasm: true }, + { name: "a WASM kernel", nudge: nudgeKernel, threads: 0, wasm: true }, + { name: "a JS kernel", nudge: nudgeKernel, threads: 0, wasm: false }, + { name: "a WASM kernel on workers", nudge: nudgeKernel, threads: 2, wasm: true }, + { name: "a JS kernel on workers", nudge: nudgeKernel, threads: 2, wasm: false }, + ]; + it.each(cases)("upload only the chunks they changed: $name", async ({ nudge, threads, wasm }) => { + const own = await device(); + const sizes = uploadSizes(own); + const gpuWorld = createWorld({ [workers]: threads, [gpuDevice]: own }, { minimumChunk: 64, wasm }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + // Loaded before a GPU system uses what it writes, so it loads again to stamp it. + await world.run(nudge); + await world.run(move); + } + // The first upload is whole: 2,500 rows each of the tagged and untagged archetypes' positions and velocities. + expect(sizes.length).toBe(4); + const stride = sizes[0] / (count / 2); + for (const world of [gpuWorld, cpuWorld]) await world.run(nudge); + sizes.length = 0; + for (const world of [gpuWorld, cpuWorld]) await world.run(move); + // The nudged row is the tagged archetype's row 1,499 (entity 2,999), in its second chunk of 1,024 rows. + expect(sizes).toEqual([1024 * stride]); + expect(await positions(gpuWorld)).toEqual(await positions(cpuWorld)); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/gpu.ts b/packages/ecs/src/implementation/world/gpu.ts new file mode 100644 index 000000000..b20a2d3b9 --- /dev/null +++ b/packages/ecs/src/implementation/world/gpu.ts @@ -0,0 +1,393 @@ +import { labelOfItem } from '../../interface/registry/index.js'; +import { layout } from '../../interface/gpu/layout.js'; +import { recordBytes } from '../../interface/gpu/record.js'; +import type { Archetype, Storage } from './archetype.js'; +import { chunkShift } from './kernel.js'; + +export { recordBytes }; +export const recordWords = recordBytes / 4; + +/** Smallest heap block, and the alignment of every block. */ +const minimumBlockBytes = 256; +const initialHeapBytes = 1 << 20; + +/** + * The GPU copy of one storage: a block of the heap holding `capacity` rows at the storage's stride. `dirty` rows + * changed on the CPU since they were last uploaded, beyond those in chunks a ticked storage stamped after `uploaded`; + * `ahead` rows were written on the GPU since they were last read back. Each is a row range, empty when `low >= high`. + */ +type Resident = { + readonly storage: Storage; + readonly archetype: Archetype; + readonly stride: number; + offset: number; + bytes: number; + capacity: number; + dirtyLow: number; + dirtyHigh: number; + aheadLow: number; + aheadHigh: number; + /** The tick of the run that last uploaded it: CPU writes since stamp later ticks. */ + uploaded: number; +}; + +/** + * A world's GPU state: a heap buffer holding the GPU copies of the storages GPU systems use, one buffer of 256-byte + * records, and the run's command encoder. Nothing here is exposed: render bodies get bind calls, not buffers, so + * both buffers can be replaced as they grow. + */ +export type GpuEngine = { + readonly device: GPUDevice; + /** Heap (read-only storage) at binding 0, a table record (uniform, dynamic offset) at binding 1. */ + readonly renderLayout: GPUBindGroupLayout; + /** Heap (read-write storage) at binding 0, a parameter record (read-only storage, dynamic offset) at binding 1. */ + readonly computeLayout: GPUBindGroupLayout; + readonly renderGroup: () => GPUBindGroup; + readonly computeGroup: () => GPUBindGroup; + /** The run's encoder, created on first use. */ + readonly encoder: () => GPUCommandEncoder; + readonly submit: () => void; + /** Drops the run's encoder unsubmitted, after a body failed partway through recording. */ + readonly discard: () => void; + /** The word address of a storage's GPU copy, uploading rows the CPU changed first, for a run at `tick`. */ + readonly upload: (archetype: Archetype, storage: Storage, tick: number) => number; + /** + * The word address of a block for `key` holding the first `count` elements of `array`, written when `changed` or + * when the block moves, such as the row lists an `up` system's dispatches read. + */ + readonly uploadIndexes: (key: string, array: Int32Array, count: number, changed: boolean) => number; + /** Writes a record for `key` (one per system and archetype) and returns its dynamic offset. */ + readonly record: (key: string, words: Uint32Array) => number; + /** Frees the records and index blocks of keys starting with `prefix`, a system's, once nothing runs it again. */ + readonly release: (prefix: string) => void; + /** For tests of what a world holds: how many records and index blocks it keeps. */ + readonly records: () => number; + /** Marks every row of a storage changed on the CPU, for writes that may not stamp its chunks. */ + readonly markCpuWrites: (archetype: Archetype, storage: Storage) => void; + readonly markGpuWrites: (archetype: Archetype, storage: Storage) => void; + /** Whether a storage has GPU writes the CPU hasn't seen. */ + readonly isAhead: (storage: Storage) => boolean; + readonly anyAhead: () => boolean; + /** Copies GPU writes back into the CPU storages; every storage when `storages` is omitted. */ + readonly readback: (storages?: readonly Storage[]) => Promise; + /** Before a sync point: notes row counts, so the rows it only appends are uploaded alone. */ + readonly beforeSync: () => void; + /** Once a sync point is planned: the archetypes whose existing rows it moves or writes, read back first and uploaded whole. */ + readonly disturb: (disturbed: ReadonlySet) => void; + /** After a sync point: the archetypes it changed, whose new rows, or all rows if disturbed, are uploaded next. */ + readonly touched: (archetypes: Iterable) => void; + /** Set once the device is lost; later runs that use the GPU reject with it, and readbacks keep the CPU's copies. */ + readonly lost: () => string | null; + /** Records the first error a run's scopes report, for the next run to reject with. */ + /** Keeps the first error of a run's GPU work, with the label of the system whose run submitted it. */ + readonly reportErrors: (system: { readonly name: string }, validation: Promise, memory: Promise) => void; + /** The recorded error, cleared. */ + readonly takeError: () => { readonly name: string; readonly message: string } | null; + readonly destroy: () => void; +}; + +const sizeClass = (bytes: number): number => Math.max(0, Math.ceil(Math.log2(Math.max(bytes, minimumBlockBytes) / minimumBlockBytes))); + +export const createGpu = (device: GPUDevice): GpuEngine => { + const renderLayout = layout(device); + const computeLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }, + { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage", hasDynamicOffset: true, minBindingSize: recordBytes } }, + ], + }); + const heapUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST; + const recordUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST; + let heap = device.createBuffer({ size: initialHeapBytes, usage: heapUsage }); + let top = 0; + const freeBlocks: number[][] = []; + let records = device.createBuffer({ size: 64 * recordBytes, usage: recordUsage }); + const slots = new Map(); + /** Record slots released, reused before new ones. */ + const freeSlots: number[] = []; + let nextSlot = 0; + let groups: { render: GPUBindGroup; compute: GPUBindGroup } | null = null; + let encoder: GPUCommandEncoder | null = null; + let lostReason: string | null = null; + let recorded: { readonly name: string; readonly message: string } | null = null; + const residents = new Map(); + const ahead = new Set(); + void device.lost.then(info => { lostReason = info.message === "" ? info.reason : info.message; }); + + const bindGroups = (): { render: GPUBindGroup; compute: GPUBindGroup } => groups ??= { + render: device.createBindGroup({ layout: renderLayout, entries: [{ binding: 0, resource: { buffer: heap } }, { binding: 1, resource: { buffer: records, size: recordBytes } }] }), + compute: device.createBindGroup({ layout: computeLayout, entries: [{ binding: 0, resource: { buffer: heap } }, { binding: 1, resource: { buffer: records, size: recordBytes } }] }), + }; + + const submit = (): void => { + if (encoder === null) return; + device.queue.submit([encoder.finish()]); + encoder = null; + }; + + const limit = Math.min(device.limits.maxBufferSize, device.limits.maxStorageBufferBindingSize); + + /** Replaces a buffer with one of `size` bytes holding its first `used` bytes. Work recorded so far is submitted first. */ + const replace = (old: GPUBuffer, size: number, usage: number, used: number): GPUBuffer => { + submit(); + const grown = device.createBuffer({ size, usage }); + if (used > 0) { + const copy = device.createCommandEncoder(); + copy.copyBufferToBuffer(old, 0, grown, 0, used); + device.queue.submit([copy.finish()]); + } + old.destroy(); + groups = null; + return grown; + }; + + const allocate = (bytes: number): number => { + const size = sizeClass(bytes); + const reused = freeBlocks[size]?.pop(); + if (reused !== undefined) return reused; + const offset = top; + const end = top + minimumBlockBytes * 2 ** size; + if (end > heap.size) { + if (end > limit) throw new Error(`The GPU copy of the world needs ${end} bytes, over this device's limit of ${limit}; request a higher maxBufferSize and maxStorageBufferBindingSize.`); + let next = heap.size; + while (next < end) next *= 2; + heap = replace(heap, Math.min(next, limit), heapUsage, offset); + } + top = end; + return offset; + }; + + const residentOf = (archetype: Archetype, storage: Storage): Resident => { + let resident = residents.get(storage); + if (resident === undefined) { + const stride = storage.layout.kind === "linear" ? storage.layout.stride : 0; + resident = { storage, archetype, stride, offset: 0, bytes: 0, capacity: 0, dirtyLow: 0, dirtyHigh: 0, aheadLow: 0, aheadHigh: 0, uploaded: 0 }; + residents.set(storage, resident); + } + if (resident.capacity < archetype.capacity) { + const bytes = Math.max(minimumBlockBytes, archetype.capacity * resident.stride); + const offset = allocate(bytes); + if (resident.bytes === 0) { + resident.dirtyLow = 0; + resident.dirtyHigh = archetype.count; + } + else { + // The old rows move on the GPU, where they may be newer than the CPU's; a buffer can't copy into itself. + submit(); + const used = resident.capacity * resident.stride; + const scratch = device.createBuffer({ size: used, usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); + const copy = device.createCommandEncoder(); + copy.copyBufferToBuffer(heap, resident.offset, scratch, 0, used); + copy.copyBufferToBuffer(scratch, 0, heap, offset, used); + device.queue.submit([copy.finish()]); + scratch.destroy(); + (freeBlocks[sizeClass(resident.bytes)] ??= []).push(resident.offset); + } + resident.offset = offset; + resident.bytes = bytes; + resident.capacity = archetype.capacity; + } + return resident; + }; + + /** Writes rows `low` to `high` of a storage into its GPU copy. */ + const write = (resident: Resident, low: number, high: number): void => { + // A queue write lands before work recorded but not yet submitted, which may read these rows. + submit(); + const start = low * resident.stride; + device.queue.writeBuffer(heap, resident.offset + start, resident.storage.views.u8, start, (high - low) * resident.stride); + }; + + const upload = (archetype: Archetype, storage: Storage, tick: number): number => { + const resident = residentOf(archetype, storage); + const { count } = archetype; + const low = resident.dirtyLow; + const high = Math.min(resident.dirtyHigh, count); + // Rows the GPU wrote and the CPU hasn't read back are newer there, so only the dirty rows are written: CPU + // systems read them back before writing them, so nothing else changed. + if (storage.ticked && !ahead.has(resident)) { + // A write per run of adjacent chunks stamped since the last upload or holding dirty rows. Outside the dirty + // rows the CPU's rows are the GPU's, so writing whole chunks costs only bytes. + const { chunkChanged } = storage; + const { uploaded } = resident; + const chunks = (count + (1 << chunkShift) - 1) >> chunkShift; + const dirtyFirst = low < high ? low >> chunkShift : chunks; + const dirtyEnd = low < high ? ((high - 1) >> chunkShift) + 1 : 0; + let first = -1; + for (let chunk = 0; chunk < chunks; chunk++) { + if (chunkChanged[chunk] > uploaded || (chunk >= dirtyFirst && chunk < dirtyEnd)) { + if (first < 0) first = chunk; + } + else if (first >= 0) { + write(resident, first << chunkShift, chunk << chunkShift); + first = -1; + } + } + if (first >= 0) write(resident, first << chunkShift, count); + } + else if (low < high) write(resident, low, high); + resident.dirtyLow = resident.dirtyHigh = 0; + resident.uploaded = tick; + return resident.offset / 4; + }; + + const indexBlocks = new Map(); + const uploadIndexes = (key: string, array: Int32Array, count: number, changed: boolean): number => { + const bytes = Math.max(minimumBlockBytes, count * 4); + let block = indexBlocks.get(key); + let write = changed; + if (block === undefined || block.bytes < bytes) { + if (block !== undefined) (freeBlocks[sizeClass(block.bytes)] ??= []).push(block.offset); + block = { offset: allocate(bytes), bytes: minimumBlockBytes * 2 ** sizeClass(bytes) }; + indexBlocks.set(key, block); + write = true; + } + if (write && count > 0) { + // A queue write lands before work recorded but not yet submitted, which may read the old lists. + submit(); + device.queue.writeBuffer(heap, block.offset, array, 0, count); + } + return block.offset / 4; + }; + + const scratch = new Uint32Array(recordWords); + const record = (key: string, words: Uint32Array): number => { + let slot = slots.get(key); + if (slot === undefined) { + slot = freeSlots.pop() ?? nextSlot++; + // Records written earlier in this run may not be bound yet, so they move with the buffer. + if ((slot + 1) * recordBytes > records.size) records = replace(records, records.size * 2, recordUsage, slot * recordBytes); + slots.set(key, slot); + } + scratch.fill(0); + scratch.set(words.subarray(0, recordWords)); + device.queue.writeBuffer(records, slot * recordBytes, scratch); + return slot * recordBytes; + }; + + const release = (prefix: string): void => { + for (const [key, slot] of slots) { + if (!key.startsWith(prefix)) continue; + slots.delete(key); + freeSlots.push(slot); + } + for (const [key, block] of indexBlocks) { + if (!key.startsWith(prefix)) continue; + indexBlocks.delete(key); + (freeBlocks[sizeClass(block.bytes)] ??= []).push(block.offset); + } + }; + + const markCpuWrites = (archetype: Archetype, storage: Storage): void => { + const resident = residents.get(storage); + if (resident === undefined) return; + resident.dirtyLow = 0; + resident.dirtyHigh = Math.max(resident.dirtyHigh, archetype.count); + }; + + const markGpuWrites = (archetype: Archetype, storage: Storage): void => { + const resident = residents.get(storage); + if (resident === undefined) return; + resident.aheadLow = 0; + resident.aheadHigh = Math.max(resident.aheadHigh, archetype.count); + ahead.add(resident); + }; + + const readback = async (storages?: readonly Storage[]): Promise => { + // A lost device's copies are gone, so the CPU's last copies stand. + if (lostReason !== null) { + ahead.clear(); + return; + } + const pending = storages === undefined ? [...ahead] : storages.flatMap(storage => { + const resident = residents.get(storage); + return resident !== undefined && ahead.has(resident) ? [resident] : []; + }); + if (pending.length === 0) return; + submit(); + const copy = device.createCommandEncoder(); + const staged = pending.map(resident => { + const start = resident.aheadLow * resident.stride; + const bytes = (resident.aheadHigh - resident.aheadLow) * resident.stride; + const staging = device.createBuffer({ size: Math.max(4, bytes), usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + if (bytes > 0) copy.copyBufferToBuffer(heap, resident.offset + start, staging, 0, bytes); + return { resident, staging, start, bytes }; + }); + device.queue.submit([copy.finish()]); + await Promise.all(staged.map(({ staging }) => staging.mapAsync(GPUMapMode.READ))); + for (const { resident, staging, start, bytes } of staged) { + if (bytes > 0) resident.storage.views.u8.set(new Uint8Array(staging.getMappedRange(0, bytes)), start); + staging.destroy(); + resident.aheadLow = resident.aheadHigh = 0; + ahead.delete(resident); + } + }; + + let disturbedNow: ReadonlySet = new Set(); + /** Row counts of archetypes with GPU copies, before the sync point. */ + const countsBefore = new Map(); + const beforeSync = (): void => { + disturbedNow = new Set(); + countsBefore.clear(); + for (const resident of residents.values()) countsBefore.set(resident.archetype, resident.archetype.count); + }; + + const touched = (archetypes: Iterable): void => { + for (const archetype of archetypes) { + const from = disturbedNow.has(archetype) ? 0 : countsBefore.get(archetype) ?? 0; + for (const storage of archetype.storages) { + const resident = residents.get(storage); + if (resident === undefined || from >= archetype.count) continue; + // A disturbed archetype was read back whole, so every row is the CPU's; otherwise only the new ones are. + resident.dirtyLow = resident.dirtyHigh > resident.dirtyLow ? Math.min(resident.dirtyLow, from) : from; + resident.dirtyHigh = Math.max(resident.dirtyHigh, archetype.count); + } + } + disturbedNow = new Set(); + }; + + return { + device, + renderLayout, + computeLayout, + renderGroup: () => bindGroups().render, + computeGroup: () => bindGroups().compute, + encoder: () => encoder ??= device.createCommandEncoder(), + submit, + discard: () => { encoder = null; }, + upload, + uploadIndexes, + record, + release, + records: () => slots.size + indexBlocks.size, + markCpuWrites, + markGpuWrites, + isAhead: storage => { + const resident = residents.get(storage); + return resident !== undefined && ahead.has(resident); + }, + anyAhead: () => ahead.size > 0, + readback, + beforeSync, + disturb: disturbed => { disturbedNow = disturbed; }, + touched, + lost: () => lostReason, + reportErrors: (system, validation, memory) => { + void (async () => { + const [invalid, exhausted] = await Promise.all([validation, memory]); + if (invalid !== null) recorded ??= { name: labelOfItem(system), message: `validation: ${invalid.message}` }; + else if (exhausted !== null) recorded ??= { name: labelOfItem(system), message: `out of memory: ${exhausted.message}` }; + })(); + }, + takeError: () => { + const error = recorded; + recorded = null; + return error; + }, + destroy: () => { + submit(); + heap.destroy(); + records.destroy(); + }, + }; +}; diff --git a/packages/ecs/src/implementation/world/holders.ts b/packages/ecs/src/implementation/world/holders.ts new file mode 100644 index 000000000..fc5530a5f --- /dev/null +++ b/packages/ecs/src/implementation/world/holders.ts @@ -0,0 +1,68 @@ +import { indexOf } from './entities.js'; + +/** + * The holders of each target for one reference component, as linked lists by entity index: holder index `h` is in + * the list of `indexOf(targets[h])`, after `next[h] - 1` and before `previous[h] - 1` (0 for none), and holds + * `targets[h]`, generation included, so a list never mixes a target's generations. + */ +export type Holders = { first: Int32Array; next: Int32Array; previous: Int32Array; targets: Float64Array; holders: Float64Array }; + +export const createHolders = (): Holders => ({ first: new Int32Array(0), next: new Int32Array(0), previous: new Int32Array(0), targets: new Float64Array(0), holders: new Float64Array(0) }); + +const grownInt = (array: Int32Array, length: number): Int32Array => { + if (array.length >= length) return array; + const grown = new Int32Array(Math.max(length, 2 * array.length)); + grown.set(array); + return grown; +}; + +const grownFloat = (array: Float64Array, length: number): Float64Array => { + if (array.length >= length) return array; + const grown = new Float64Array(Math.max(length, 2 * array.length)); + grown.set(array); + return grown; +}; + +/** Grows the lists to cover entity indexes below `length`. */ +export const fitHolders = (lists: Holders, length: number): void => { + if (lists.first.length >= length) return; + lists.first = grownInt(lists.first, length); + lists.next = grownInt(lists.next, length); + lists.previous = grownInt(lists.previous, length); + lists.targets = grownFloat(lists.targets, length); + lists.holders = grownFloat(lists.holders, length); +}; + +/** Adds `holder` to the list of `target`. Each holder is in at most one list, so it must be unlinked first. */ +export const linkHolder = (lists: Holders, target: number, holder: number): void => { + const at = indexOf(holder); + const into = indexOf(target); + const first = lists.first[into]; + lists.next[at] = first; + lists.previous[at] = 0; + if (first !== 0) lists.previous[first - 1] = at + 1; + lists.first[into] = at + 1; + lists.targets[at] = target; + lists.holders[at] = holder; +}; + +/** Removes `holder` from the list of `target`, if it's there. */ +export const unlinkHolder = (lists: Holders, target: number, holder: number): void => { + const at = indexOf(holder); + if (at >= lists.first.length || lists.targets[at] !== target || lists.holders[at] !== holder) return; + const previous = lists.previous[at]; + const next = lists.next[at]; + if (previous !== 0) lists.next[previous - 1] = next; + else lists.first[indexOf(target)] = next; + if (next !== 0) lists.previous[next - 1] = previous; + lists.targets[at] = 0; +}; + +/** Calls `each` with every holder of `target`. */ +export const forEachHolder = (lists: Holders, target: number, each: (holder: number) => void): void => { + const into = indexOf(target); + if (into >= lists.first.length) return; + for (let at = lists.first[into]; at !== 0; at = lists.next[at - 1]) { + if (lists.targets[at - 1] === target) each(lists.holders[at - 1]); + } +}; diff --git a/packages/ecs/src/implementation/world/ids.test.ts b/packages/ecs/src/implementation/world/ids.test.ts new file mode 100644 index 000000000..4e819af69 --- /dev/null +++ b/packages/ecs/src/implementation/world/ids.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { identify } from "../../interface/registry/ids.test-helper.js"; +import { workers } from "../../interface/resources/workers.js"; +import { Rule } from "../../interface/rule/rule.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { read } from "./reads.test-helper.js"; + +// Each pair is one module's key and the key that module makes when evaluated again: one id, two symbols. +const score = Symbol("score"); +const scoreAgain = Symbol("score"); +const shown = Symbol("shown"); +const shownAgain = Symbol("shown"); + +identify(score, "worldIdsTest/keys.ts#score"); +identify(scoreAgain, "worldIdsTest/keys.ts#score"); +identify(shown, "worldIdsTest/keys.ts#shown"); +identify(shownAgain, "worldIdsTest/keys.ts#shown"); + +declare module "@adobe/ecs" { + export interface Components { + [score]: { type: number }; + [scoreAgain]: { type: number }; + [shown]: { type: true; derived: true }; + [shownAgain]: { type: true; derived: true }; + } +} + +Component.register(score, { type: { type: "number" } }); +Component.register(shown, { type: { const: true }, derived: true }); +Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [score] }, derive: [shown] }, "worldIdsTest/systems.ts#shown")); +const changes: (readonly symbol[])[] = []; +Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [score] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { + for (const group of groups) changes.push(group.added); +}); + +// The module evaluated again, after the world met its first keys. +const evaluateAgain = (): void => { + Component.register(scoreAgain, { type: { type: "number" } }); + Component.register(shownAgain, { type: { const: true }, derived: true }); + Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [scoreAgain] }, derive: [shownAgain] }, "worldIdsTest/systems.ts#shown")); + Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [scoreAgain] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { + for (const group of groups) changes.push(group.added); + }); +}; + +const spawn = System.create({ name: "worldIdsTest/spawn" }, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); +const spawned: Entity[] = []; + +describe("a world given keys with ids", () => { + it("stores the symbols of one id as one component, giving values by the newest", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + evaluateAgain(); + const spawnAgain = System.create({ name: "worldIdsTest/spawnAgain" }, ({ commands }) => { spawned.push(commands.spawn([scoreAgain])({ [scoreAgain]: 2 })); }); + const add = System.create({ name: "worldIdsTest/add", components: { write: [scoreAgain] } }, row => { row[scoreAgain] += 10; }); + await world.run(spawnAgain); + await world.run(add); + const [first, second] = spawned; + expect([await read(world, first, score), await read(world, first, scoreAgain), await read(world, second, score)]).toEqual([11, 11, 12]); + expect([await read(world, first, shownAgain), await read(world, second, shown)]).toEqual([true, true]); + expect(changes).toEqual([[score], [scoreAgain]]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/imported-lists.test-helper.ts b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts new file mode 100644 index 000000000..0be2fb101 --- /dev/null +++ b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts @@ -0,0 +1,22 @@ +import { Archetype } from '../../interface/archetype/archetype.js'; +import { Component } from '../../interface/component/component.js'; + +/** Keys and lists another module's kernels import, in `imported-lists.test.ts`. */ + +export const ember = Symbol("importedListsTest/ember"); +/** A key only this module's lists name to the kernels. */ +const glow = Symbol("importedListsTest/glow"); +export const marked = Symbol("importedListsTest/marked"); +declare module "@adobe/ecs" { + export interface Components { + [ember]: { type: number }; + [glow]: { type: true }; + [marked]: { type: true }; + } +} +Component.register(ember, { type: { type: "number" } }); +Component.register(glow, { type: { const: true } }); +Component.register(marked, { type: { const: true } }); + +export const Spark = Archetype.create([ember, glow]); +export const Marks = [ember, marked] as const; diff --git a/packages/ecs/src/implementation/world/imported-lists.test.ts b/packages/ecs/src/implementation/world/imported-lists.test.ts new file mode 100644 index 000000000..5592d7530 --- /dev/null +++ b/packages/ecs/src/implementation/world/imported-lists.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { System } from "../../interface/system/system.js"; +import { count, read, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { ember, marked, Spark } from "./imported-lists.test-helper.js"; +import * as Lists from "./imported-lists.test-helper.js"; +import { workers } from "../../interface/resources/workers.js"; + +const heat = Symbol("importedListsTest/heat"); +declare module "@adobe/ecs" { + export interface Components { + [heat]: { type: number }; + } +} +Component.register(heat, { type: { type: "number" } }); + +const seed = System.create({ name: "importedListsTest/seed" }, ({ commands }) => { + commands.spawn([heat])({ [heat]: 2 }); + commands.spawn([heat])({ [heat]: 5 }); +}); + +/** Spawns and adds through lists another module declares, naming one of their keys only through the list. */ +const ignite = System.create({ name: "importedListsTest/ignite", kernel: "cpu", components: { read: [heat] } }, (row, { commands }) => { + commands.spawn(Spark)({ [ember]: row[heat] * 2 }); + commands.add(row.entity, Lists.Marks, { [ember]: row[heat] }); +}); + +/** Declares an imported list, and removes through one. */ +const cool = System.create({ name: "importedListsTest/cool", kernel: "cpu", components: { read: [ember], has: Lists.Spark } }, (row, { commands }) => { + if (row[ember] > 4) commands.remove(row.entity, Spark); +}); + +it("kernels spawn, add, remove and declare through lists imported from another module", async () => { + // Kernels run here: under Vitest's module runner, node workers can't load the sources. + await using world = createWorld({ [workers]: 0 }); + await world.run(seed); + await world.run(ignite); + const sparks = await select(world, { required: Spark }); + expect((await Promise.all(sparks.map(entity => read(world, entity, ember)))).sort((a, b) => (a ?? 0) - (b ?? 0))).toEqual([4, 10]); + expect(await count(world, { required: [heat, ...Lists.Marks] })).toBe(2); + await world.run(cool); + expect(await count(world, { required: Spark })).toBe(1); + expect(await count(world, { required: [marked] })).toBe(2); +}); diff --git a/packages/ecs/src/implementation/world/index-build-csp.test.ts b/packages/ecs/src/implementation/world/index-build-csp.test.ts new file mode 100644 index 000000000..41309e24a --- /dev/null +++ b/packages/ecs/src/implementation/world/index-build-csp.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; + +const spot = Symbol("indexBuildCspTest/spot"); +const size = Symbol("indexBuildCspTest/size"); +const bulk = Symbol("indexBuildCspTest/bulk"); +declare module "@adobe/ecs" { + interface Components { + [spot]: { type: readonly [number, number] }; + [size]: { type: number }; + [bulk]: { type: number }; + } +} +Component.register(spot, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); +Component.register(size, { type: { type: "number" } }); +Component.register(bulk, { type: { type: "number", precision: 2 } }); + +/** A key of a pair of i32s and an f64, so the steps encode three kinds of leaf. */ +const bySpot = Index.create({ key: [spot, bulk], components: { has: [size] } }); +const count = 70_000; + +describe("index builds in phases where code can't be generated", () => { + it("list keys in a loop over their leaves, building the same index", async () => { + const generate = globalThis.Function; + // A content security policy without 'unsafe-eval' makes `new Function` throw. + globalThis.Function = new Proxy(generate, { construct: () => { throw new EvalError("blocked"); } }); + try { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create(({ commands }) => { + for (let k = 0; k < count; k++) commands.spawn([spot, size, bulk])({ [spot]: [k % 37 - 18, k % 11], [size]: k, [bulk]: (k % 3) / 4 }); + })); + const expected = new Map(); + await world.run(System.create({ components: { read: [spot, size, bulk] } }, row => { + const text = `${row[spot][0]},${row[spot][1]},${row[bulk]}`; + const held = expected.get(text) ?? []; + held.push(row[size]); + expected.set(text, held); + })); + const found = new Map(); + await world.run(System.create({ lookup: { at: { index: bySpot, read: [size] } } }, ({ lookup }) => { + for (const text of expected.keys()) { + const [x, y, b] = text.split(",").map(Number); + const list = lookup.at([x, y], b); + found.set(text, Array.from({ length: list.length }, (_, i) => list.at(i)[size])); + } + })); + // Rows were spawned in entity order, so each bucket's sizes ascend. + expect(found).toEqual(new Map([...expected].map(([text, sizes]) => [text, [...sizes].sort((a, b) => a - b)]))); + expect((await world.tables()).phasedBuilds).toBe(1); + } + finally { + globalThis.Function = generate; + } + }); +}); diff --git a/packages/ecs/src/implementation/world/index-build.cases.ts b/packages/ecs/src/implementation/world/index-build.cases.ts new file mode 100644 index 000000000..a79e0d191 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-build.cases.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./index-build.fixture.js"; + +/** Indexes built again across the pool: the cases, over the fixture built for node or from source. */ +export const indexBuildCases = (load: () => Promise) => describe("index builds across the pool", () => { + it("give the same index whatever the number of workers, as this thread alone builds it, and what the rows hold", async () => { + const fixture = await load(); + const alone = await fixture.simulate(0); + expect(alone.folds).toEqual(alone.expected); + expect(alone.failures.length).toBeGreaterThan(0); + expect(alone.pooled).toBe(0); + for (const threads of [1, 2, 3]) { + const built = await fixture.simulate(threads); + expect(built.folds).toEqual(alone.folds); + expect(built.failures).toEqual(alone.failures); + // Both builds of the grid and the one of the serials, whose keys don't change, ran across the pool. + expect(built.pooled).toBe(3); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/index-build.fixture.ts b/packages/ecs/src/implementation/world/index-build.fixture.ts new file mode 100644 index 000000000..202067d61 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-build.fixture.ts @@ -0,0 +1,125 @@ +import { Component } from "../../interface/component/component.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +/** Scenarios of indexes built again across the pool, run built in node and from source in chromium. */ + +const cell = Symbol("indexBuildTest/cell"); +const serial = Symbol("indexBuildTest/serial"); +const weight = Symbol("indexBuildTest/weight"); + +declare module "@adobe/ecs" { + interface Components { + [cell]: { type: readonly [number, number] }; + [serial]: { type: number }; + [weight]: { type: number }; + } +} +Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); +Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 10000000 } }); +Component.register(weight, { type: { type: "number" } }); + +const grid = Index.create({ name: "indexBuildTest/grid", key: [cell], components: { has: [weight] } }); +const serials = Index.create({ name: "indexBuildTest/serials", key: [serial], unique: true }); + +const width = 300; +const height = 200; +const count = 150_000; + +/** Spawns entities in cells, with serials of which a few repeat. */ +const seed = System.create({ name: "indexBuildTest/seed" }, ({ commands }) => { + for (let k = 0; k < count; k++) { + commands.spawn([cell, serial, weight])({ [cell]: [(k * 7919) % width, (k * 104729) % height], [serial]: k % 50_000 === 7 ? 7 : k, [weight]: k % 13 }); + } +}); + +/** Moves every entity to another cell, so the next refresh builds the index again. */ +const shuffle = System.create({ name: "indexBuildTest/shuffle", components: { write: [cell] } }, row => { + const [x, y] = row[cell]; + row[cell] = [(x * 7 + 3) % width, (y + x) % height]; +}); + +/** A kernel, so the pool starts, as it does with a world's first kernel. */ +const warm = System.create({ name: "indexBuildTest/warm", kernel: "cpu", components: { read: [serial], write: [weight] } }, row => { + row[weight] = row[serial] % 13; +}); + +const seen = { grid: 0, rows: 0, serials: 0 }; + +/** Every cell's rows, in order, folded into one number; and the unique index's row of each serial. */ +const read = System.create({ + name: "indexBuildTest/read", + lookup: { near: { index: grid, read: [weight] }, bySerial: { index: serials } }, +}, ({ lookup }) => { + let fold = 0; + let rows = 0; + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + const near = lookup.near([x, y]); + for (let i = 0; i < near.length; i++) { + const row = near.at(i); + fold = (Math.imul(fold ^ row.entity, 0x9E3779B1) + row[weight] + i) | 0; + rows++; + } + } + } + let serialFold = 0; + for (let s = 0; s < count; s += 37) serialFold = (Math.imul(serialFold, 31) + (lookup.bySerial(s)?.entity ?? -1)) | 0; + seen.grid = fold; + seen.rows = rows; + seen.serials = serialFold; +}); + +/** The same folds without an index: each cell's rows sorted by entity, and each serial's lowest entity. */ +const reference = System.create({ name: "indexBuildTest/reference", components: { read: [cell, serial, weight] } }, row => { + const [x, y] = row[cell]; + const cellRows = byCell[x * height + y] ??= []; + cellRows.push([row.entity, row[weight]]); + const held = lowest.get(row[serial]); + if (held === undefined || row.entity < held) lowest.set(row[serial], row.entity); +}); +let byCell: (readonly [number, number])[][] = []; +const lowest = new Map(); +const referenceFolds = (): readonly number[] => { + let fold = 0; + let rows = 0; + for (let k = 0; k < width * height; k++) { + const sorted = (byCell[k] ?? []).sort((a, b) => a[0] - b[0]); + sorted.forEach(([entity, value], i) => { + fold = (Math.imul(fold ^ entity, 0x9E3779B1) + value + i) | 0; + rows++; + }); + } + let serialFold = 0; + for (let s = 0; s < count; s += 37) serialFold = (Math.imul(serialFold, 31) + (lowest.get(s) ?? -1)) | 0; + return [fold, rows, serialFold]; +}; + +/** What two builds gave, the second after every key changed, and the same without an index, the unique index's failures, and how many builds ran across the pool. */ +export const simulate = async (threads: number): Promise<{ readonly folds: readonly number[]; readonly expected: readonly number[]; readonly failures: readonly string[]; readonly pooled: number }> => { + const world = createWorld({ [workers]: threads }); + try { + await world.run(seed); + await world.run(warm); + const folds: number[] = []; + const expected: number[] = []; + const failures: string[] = []; + for (let round = 0; round < 2; round++) { + const report = await runReported(world, read); + failures.push(...report.failures.map(failure => `${failure.kind} ${failure.name} ${failure.message}`)); + folds.push(seen.grid, seen.rows, seen.serials); + byCell = []; + lowest.clear(); + await world.run(reference); + expected.push(...referenceFolds()); + await world.run(shuffle); + } + return { folds, expected, failures, pooled: (await world.tables()).pooledBuilds }; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/index-build.ts b/packages/ecs/src/implementation/world/index-build.ts new file mode 100644 index 000000000..3c5b7bf77 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-build.ts @@ -0,0 +1,458 @@ +import { capacityFor, entryWords, recordCapacity, recordHash, recordKey, recordLength, recordStart } from './index-hash.js'; +import { hashWords, orderF32, orderF64 } from './index-keys.js'; + +// A hash index built again across the pool (design.md, "Indexes"): in steps over fixed pieces of entity indexes and +// fixed partitions of the slot table, each step's pieces or partitions claimed by workers, so what's built doesn't +// depend on how many there are or which ran what. A step reads its plan, and everything it reads and writes, in the +// memory, so it travels through the pool's task rings as a task of `buildKernel`. + +/** The kernel number of a build step's task: its phase as the archetype, its pieces or partitions as rows, its plan's address its scalar. */ +export const buildKernel = -2; +/** Entity indexes a piece covers. */ +export const pieceEntities = 1 << 15; +/** Partitions of the slot table, each a contiguous range of slots, at most. */ +export const maximumPartitions = 256; + +export type Phase = "count" | "list" | "group" | "fill"; +/** Phases by number, as a task's archetype carries them. */ +export const phases: readonly Phase[] = ["count", "list", "group", "fill"]; + +/** A leaf's stored type, as a number a plan carries. */ +export const scalarCodes = { f32: 0, i32: 1, u32: 2, boolean: 3, f64: 4 } satisfies { readonly [scalar: string]: number }; + +/** + * A build's plan: byte offsets into the memory of what it reads and writes, and small arrays. `members` is by + * archetype id the archetype's slot among members, or -1; `columns` is by member slot and leaf the byte address of the + * leaf's column; each leaf has its code, unit stride and offset, and first key word. + */ +export type HashBuild = { + readonly entityArchetype: number; + readonly entityRow: number; + /** Entity indexes from 1 up to this are walked. */ + readonly entityEnd: number; + readonly pieces: number; + readonly words: number; + readonly partitions: number; + /** A hash's partition: its slot, `hash & mask`, shifted right by this. */ + readonly shift: number; + readonly mask: number; + /** By piece and partition: entries, then where the piece's entries of the partition go. */ + readonly histogram: number; + /** By entry, grouped by partition and in entity order within each: entity index, archetype and row; key words; hash. */ + readonly grouped: number; + readonly groupedKeys: number; + readonly groupedHashes: number; + /** By grouped position: its bucket within its partition. */ + readonly local: number; + /** By partition: where its positions start (`partitions + 1`), its buckets, their capacity, those of more than one entry, where its buckets and segments start, and its overflowed buckets. */ + readonly starts: number; + readonly buckets: number; + readonly capacity: number; + readonly crowded: number; + readonly bucketBase: number; + readonly capacityBase: number; + readonly overflowed: number; + /** By grouped position: each of its partition's buckets' first position and length, and those its slots overflowed. */ + readonly first: number; + readonly lengths: number; + readonly overflow: number; + /** The table: slots, records and their stride, ticks by bucket, entries and bucket by entity. */ + readonly slots: number; + readonly records: number; + readonly stride: number; + readonly ticks: number; + readonly table: number; + readonly bucketOf: number; + readonly tick: number; + /** Whether buckets are placed in their partitions' slots, or all by the world's thread once more were found than the slots hold. */ + readonly placing: boolean; + readonly members: ArrayLike; + readonly columns: ArrayLike; + readonly leafCodes: ArrayLike; + readonly leafStrides: ArrayLike; + readonly leafOffsets: ArrayLike; + readonly leafWords: ArrayLike; + /** Watched components' ticks by bucket. */ + readonly watched: ArrayLike; +}; + +/** A plan's numbers, in the order it's written and read; its arrays follow, each its length then its elements. */ +const numbersOf = (build: HashBuild): readonly number[] => [ + build.entityArchetype, build.entityRow, build.entityEnd, build.pieces, build.words, build.partitions, build.shift, build.mask, build.histogram, build.grouped, build.groupedKeys, build.groupedHashes, build.local, build.starts, + build.buckets, build.capacity, build.crowded, build.bucketBase, build.capacityBase, build.overflowed, build.first, build.lengths, build.overflow, + build.slots, build.records, build.stride, build.ticks, build.table, build.bucketOf, build.tick, build.placing ? 1 : 0, +]; +const arraysOf = (build: HashBuild): readonly ArrayLike[] => [build.members, build.columns, build.leafCodes, build.leafStrides, build.leafOffsets, build.leafWords, build.watched]; + +/** f64 words a plan takes. */ +export const planWords = (build: HashBuild): number => { + let words = numbersOf(build).length; + for (const array of arraysOf(build)) words += 1 + array.length; + return words; +}; + +/** Writes a plan into `f64` from word `at`. */ +export const writePlan = (f64: Float64Array, at: number, build: HashBuild): void => { + let next = at; + for (const value of numbersOf(build)) f64[next++] = value; + for (const array of arraysOf(build)) { + f64[next++] = array.length; + for (let k = 0; k < array.length; k++) f64[next++] = array[k]; + } +}; + +/** A plan written at byte `address`, as `numbersOf` and `arraysOf` order it, its arrays views of the memory. */ +export const readPlan = (f64: Float64Array, address: number): HashBuild => { + let next = address >> 3; + const number = (): number => f64[next++]; + const array = (): Float64Array => { + const length = f64[next++]; + next += length; + return f64.subarray(next - length, next); + }; + return { + entityArchetype: number(), entityRow: number(), entityEnd: number(), pieces: number(), words: number(), partitions: number(), shift: number(), + mask: number(), histogram: number(), grouped: number(), groupedKeys: number(), + groupedHashes: number(), local: number(), starts: number(), buckets: number(), capacity: number(), crowded: number(), bucketBase: number(), + capacityBase: number(), overflowed: number(), first: number(), lengths: number(), overflow: number(), slots: number(), records: number(), + stride: number(), ticks: number(), table: number(), bucketOf: number(), tick: number(), placing: number() === 1, + members: array(), columns: array(), leafCodes: array(), leafStrides: array(), leafOffsets: array(), leafWords: array(), watched: array(), + }; +}; + +/** Views of the memory a step reads and writes through. */ +export type BuildViews = { readonly u8: Uint8Array; readonly u32: Uint32Array; readonly i32: Int32Array; readonly f64: Float64Array }; + +export const buildViews = (buffer: ArrayBufferLike): BuildViews => ({ u8: new Uint8Array(buffer), u32: new Uint32Array(buffer), i32: new Int32Array(buffer), f64: new Float64Array(buffer) }); + +/** Scratch of the thread running steps: a piece's partition offsets, a partition's table of buckets, and its buckets' starts and fills. */ +const scratch: { offsets: Int32Array; table: Int32Array; hashes: Int32Array; firsts: Int32Array; counts: Int32Array; starts: Int32Array; fills: Int32Array } = { + offsets: new Int32Array(maximumPartitions), table: new Int32Array(64), hashes: new Int32Array(64), firsts: new Int32Array(64), counts: new Int32Array(64), + starts: new Int32Array(64), fills: new Int32Array(64), +}; + +const ensure = (array: Int32Array, length: number): Int32Array => array.length >= length ? array : new Int32Array(Math.max(length, 2 * array.length)); + +/** Runs one phase of a build over pieces or partitions `from` to `to`. Each phase can run again over the same ones. */ +export const runBuildStep = (views: BuildViews, build: HashBuild, phase: Phase, from: number, to: number): void => { + if (phase === "count" || phase === "list") { + const step = piecesOf(build); + if (phase === "count") step.count(views, build, from, to, pair, orderF32, orderF64); + else step.list(views, build, from, to, pair, orderF32, orderF64, scratch.offsets); + } + else if (phase === "group") groupPartitions(views, build, from, to); + else fillPartitions(views, build, from, to); +}; + +/** Words for an f64's encoding. */ +const pair = new Uint32Array(2); + +/** + * A piece's steps: `count` clears its entities' buckets and counts its members by partition; `list` writes each + * member's entry, key and hash where its partition's next one goes, from the offsets the world wrote over the counts. + */ +type PieceSteps = { + readonly count: (views: BuildViews, build: HashBuild, from: number, to: number, pair: Uint32Array, orderF32: (bits: number) => number, orderF64: (high: number, low: number, out: Uint32Array, at: number) => void) => void; + readonly list: (views: BuildViews, build: HashBuild, from: number, to: number, pair: Uint32Array, orderF32: (bits: number) => number, orderF64: (high: number, low: number, out: Uint32Array, at: number) => void, offsets: Int32Array) => void; +}; + +/** + * The statements reading a member's key words from its columns at `row`, `slot` its member slot, into locals `k0`, + * `k1`... in key order, and hashing them into `hash` as `hashWords` (`index-keys.ts`) does, for the plan's leaves. + */ +const keyStatements = (build: HashBuild): string => { + const leaves = build.leafCodes.length; + const lines: string[] = [`const base = slot * ${leaves};`]; + for (let leaf = 0; leaf < leaves; leaf++) { + const index = `row * ${build.leafStrides[leaf]} + ${build.leafOffsets[leaf]}`; + const word = build.leafWords[leaf]; + lines.push(`const c${leaf} = columns[base + ${leaf}];`); + switch (build.leafCodes[leaf]) { + case 0: lines.push(`const k${word} = orderF32(u32[(c${leaf} >> 2) + ${index}]);`); break; + case 1: lines.push(`const k${word} = (u32[(c${leaf} >> 2) + ${index}] ^ 0x80000000) >>> 0;`); break; + case 2: lines.push(`const k${word} = u32[(c${leaf} >> 2) + ${index}];`); break; + case 3: lines.push(`const k${word} = u8[c${leaf} + ${index}] === 0 ? 0 : 1;`); break; + default: lines.push(`orderF64(u32[(c${leaf} >> 2) + 2 * (${index}) + 1], u32[(c${leaf} >> 2) + 2 * (${index})], pair, 0);`, `const k${word} = pair[0], k${word + 1} = pair[1];`); + } + } + lines.push("let hash = 0x811C9DC5;"); + for (let w = 0; w < build.words; w++) lines.push(`hash = Math.imul(hash ^ k${w}, 0x9E3779B1); hash ^= hash >>> 15;`); + lines.push("hash = Math.imul(hash ^ (hash >>> 16), 0x85EBCA6B); hash = Math.imul(hash ^ (hash >>> 13), 0xC2B2AE35); hash = (hash ^ (hash >>> 16)) >>> 0;"); + return lines.join("\n"); +}; + +/** The source of a piece's two steps, for one plan's leaves. */ +const pieceSource = (build: HashBuild): string => { + const loop = (inner: readonly string[]): string => [ + "const archetypes = build.entityArchetype >> 2, rows = build.entityRow >> 2, bucketOf = build.bucketOf >> 2;", + "const { members, columns, partitions, shift, mask } = build;", + "const known = members.length, histogram = build.histogram >> 2;", + "const grouped = build.grouped >> 2, groupedKeys = build.groupedKeys >> 2, groupedHashes = build.groupedHashes >> 2;", + "for (let piece = from; piece < to; piece++) {", + `const start = Math.max(1, piece * ${pieceEntities}), end = Math.min(build.entityEnd, (piece + 1) * ${pieceEntities});`, + "const counted = histogram + piece * partitions;", + ...inner, + "}", + ].join("\n"); + const member = [ + "const archetype = i32[archetypes + entity];", + "if (archetype < 0 || archetype >= known) continue;", + "const slot = members[archetype];", + "if (slot < 0) continue;", + "const row = u32[rows + entity];", + keyStatements(build), + ]; + const count = loop([ + "for (let q = 0; q < partitions; q++) i32[counted + q] = 0;", + "for (let entity = start; entity < end; entity++) {", + "i32[bucketOf + entity] = -1;", + ...member, + "i32[counted + ((hash & mask) >>> shift)]++;", + "}", + ]); + const list = loop([ + "for (let q = 0; q < partitions; q++) offsets[q] = i32[counted + q];", + "for (let entity = start; entity < end; entity++) {", + ...member, + "const position = offsets[(hash & mask) >>> shift]++;", + "u32[groupedHashes + position] = hash;", + `const target = grouped + position * ${entryWords};`, + "i32[target] = entity; i32[target + 1] = archetype; i32[target + 2] = row;", + ...Array.from({ length: build.words }, (_, w) => `u32[groupedKeys + position * ${build.words} + ${w}] = k${w};`), + "}", + ]); + return [ + "return {", + `count: ({ u8, u32, i32 }, build, from, to, pair, orderF32, orderF64) => {\n${count}\n},`, + `list: ({ u8, u32, i32 }, build, from, to, pair, orderF32, orderF64, offsets) => {\n${list}\n},`, + "};", + ].join("\n"); +}; + +/** Whether code can be generated here; a content security policy without `'unsafe-eval'` forbids it. */ +let generating = true; +/** Steps by the leaves and key words they're for. */ +const generated = new Map(); + +/** The steps for a plan's leaves: generated for them, or loops over them where code can't be generated. */ +const piecesOf = (build: HashBuild): PieceSteps => { + if (!generating) return genericSteps; + const signature = [build.words, ...Array.from(build.leafCodes), ...Array.from(build.leafStrides), ...Array.from(build.leafOffsets), ...Array.from(build.leafWords)].join(","); + const found = generated.get(signature); + if (found !== undefined) return found; + try { + // The factory returns the steps its source makes, of `PieceSteps`'s shape. + const steps: PieceSteps = new Function(pieceSource(build))(); + generated.set(signature, steps); + return steps; + } + catch { + generating = false; + return genericSteps; + } +}; + +/** A member's key words into `key` from its columns at `row`, and their hash. */ +const keyOf = ({ u8, u32 }: BuildViews, build: HashBuild, slot: number, row: number, key: Uint32Array): number => { + const { columns, leafCodes, leafStrides, leafOffsets, leafWords, words } = build; + const leaves = leafCodes.length; + for (let leaf = 0; leaf < leaves; leaf++) { + const column = columns[slot * leaves + leaf]; + const index = row * leafStrides[leaf] + leafOffsets[leaf]; + const word = leafWords[leaf]; + const code = leafCodes[leaf]; + if (code === 2) key[word] = u32[(column >> 2) + index]; + else if (code === 1) key[word] = (u32[(column >> 2) + index] ^ 0x80000000) >>> 0; + else if (code === 0) key[word] = orderF32(u32[(column >> 2) + index]); + else if (code === 3) key[word] = u8[column + index] === 0 ? 0 : 1; + else orderF64(u32[(column >> 2) + 2 * index + 1], u32[(column >> 2) + 2 * index], key, word); + } + return hashWords(key, 0, words); +}; + +const genericKey = new Uint32Array(64); + +/** `count` for any leaves. */ +const genericCount: PieceSteps["count"] = (views, build, from, to) => { + const { i32, u32 } = views; + const archetypes = build.entityArchetype >> 2; + const rows = build.entityRow >> 2; + const bucketOf = build.bucketOf >> 2; + const { members, partitions, shift, mask } = build; + const key = genericKey.length >= build.words ? genericKey : new Uint32Array(build.words); + for (let piece = from; piece < to; piece++) { + const counted = (build.histogram >> 2) + piece * partitions; + for (let q = 0; q < partitions; q++) i32[counted + q] = 0; + for (let entity = Math.max(1, piece * pieceEntities), end = Math.min(build.entityEnd, (piece + 1) * pieceEntities); entity < end; entity++) { + i32[bucketOf + entity] = -1; + const archetype = i32[archetypes + entity]; + if (archetype < 0 || archetype >= members.length || members[archetype] < 0) continue; + const hash = keyOf(views, build, members[archetype], u32[rows + entity], key); + i32[counted + ((hash & mask) >>> shift)]++; + } + } +}; + +/** `list` for any leaves. */ +const genericList: PieceSteps["list"] = (views, build, from, to, _pair, _orderF32, _orderF64, offsets) => { + const { i32, u32 } = views; + const archetypes = build.entityArchetype >> 2; + const rows = build.entityRow >> 2; + const { members, partitions, shift, mask, words } = build; + const grouped = build.grouped >> 2; + const groupedKeys = build.groupedKeys >> 2; + const groupedHashes = build.groupedHashes >> 2; + const key = genericKey.length >= words ? genericKey : new Uint32Array(words); + for (let piece = from; piece < to; piece++) { + const counted = (build.histogram >> 2) + piece * partitions; + for (let q = 0; q < partitions; q++) offsets[q] = i32[counted + q]; + for (let entity = Math.max(1, piece * pieceEntities), end = Math.min(build.entityEnd, (piece + 1) * pieceEntities); entity < end; entity++) { + const archetype = i32[archetypes + entity]; + if (archetype < 0 || archetype >= members.length || members[archetype] < 0) continue; + const row = u32[rows + entity]; + const hash = keyOf(views, build, members[archetype], row, key); + const position = offsets[(hash & mask) >>> shift]++; + u32[groupedHashes + position] = hash; + i32[grouped + position * entryWords] = entity; + i32[grouped + position * entryWords + 1] = archetype; + i32[grouped + position * entryWords + 2] = row; + for (let w = 0; w < words; w++) u32[groupedKeys + position * words + w] = key[w]; + } + } +}; + +const genericSteps: PieceSteps = { count: genericCount, list: genericList }; + +/** Each partition's buckets, in order of their first entry: its positions' buckets there, each bucket's first position and length. */ +const groupPartitions = ({ u32, i32 }: BuildViews, build: HashBuild, from: number, to: number): void => { + const { words } = build; + const starts = build.starts >> 2; + const local = build.local >> 2; + const keys = build.groupedKeys >> 2; + const hashes = build.groupedHashes >> 2; + const first = build.first >> 2; + const lengths = build.lengths >> 2; + for (let q = from; q < to; q++) { + const start = i32[starts + q]; + const end = i32[starts + q + 1]; + const entries = end - start; + let size = 16; + while (size < 2 * entries) size *= 2; + // By slot: its bucket, or -1, and that bucket's hash; by bucket: its first position and length. + const table = scratch.table = ensure(scratch.table, size); + const slotHashes = scratch.hashes = ensure(scratch.hashes, size); + const firsts = scratch.firsts = ensure(scratch.firsts, entries); + const counts = scratch.counts = ensure(scratch.counts, entries); + table.fill(-1, 0, size); + const tableMask = size - 1; + let buckets = 0; + for (let position = start; position < end; position++) { + const hash = u32[hashes + position] | 0; + // A partition's hashes share their partition's bits, so its table probes by the rest. + let slot = (hash ^ (hash >>> 16)) & tableMask; + let bucket = -1; + for (; ; slot = (slot + 1) & tableMask) { + const found = table[slot]; + if (found < 0) break; + if (slotHashes[slot] !== hash) continue; + const other = firsts[found]; + let same = true; + for (let w = 0; w < words && same; w++) same = u32[keys + other * words + w] === u32[keys + position * words + w]; + if (same) { + bucket = found; + break; + } + } + if (bucket < 0) { + bucket = buckets++; + table[slot] = bucket; + slotHashes[slot] = hash; + firsts[bucket] = position; + counts[bucket] = 0; + } + i32[local + position] = bucket; + counts[bucket]++; + } + let capacity = 0; + let crowded = 0; + for (let bucket = 0; bucket < buckets; bucket++) { + const length = counts[bucket]; + i32[first + start + bucket] = firsts[bucket]; + i32[lengths + start + bucket] = length; + capacity += capacityFor(length); + if (length > 1) crowded++; + } + i32[(build.buckets >> 2) + q] = buckets; + i32[(build.capacity >> 2) + q] = capacity; + i32[(build.crowded >> 2) + q] = crowded; + } +}; + +/** + * Each partition's buckets into the table, from where `bucketBase` and `capacityBase` say: records, ticks and slots + * within the partition's range, those past its end listed for the world's thread; and its entries into their buckets' + * segments, in entity order, with each entity's bucket. + */ +const fillPartitions = ({ u32, i32, f64 }: BuildViews, build: HashBuild, from: number, to: number): void => { + const { words, stride, mask, shift, tick, watched, placing } = build; + const starts = build.starts >> 2; + const local = build.local >> 2; + const grouped = build.grouped >> 2; + const keys = build.groupedKeys >> 2; + const hashes = build.groupedHashes >> 2; + const first = build.first >> 2; + const lengths = build.lengths >> 2; + const records = build.records >> 2; + const slots = build.slots >> 2; + const ticks = build.ticks >> 3; + const table = build.table >> 2; + const bucketOf = build.bucketOf >> 2; + const overflow = build.overflow >> 2; + for (let q = from; q < to; q++) { + const start = i32[starts + q]; + const end = i32[starts + q + 1]; + const buckets = i32[(build.buckets >> 2) + q]; + const base = i32[(build.bucketBase >> 2) + q]; + let segment = i32[(build.capacityBase >> 2) + q]; + const segments = scratch.starts = ensure(scratch.starts, buckets); + const fills = scratch.fills = ensure(scratch.fills, buckets); + const regionEnd = (q + 1) << shift; + let overflowed = 0; + for (let bucket = 0; bucket < buckets; bucket++) { + const id = base + bucket; + const length = i32[lengths + start + bucket]; + const position = i32[first + start + bucket]; + const hash = u32[hashes + position]; + const record = records + id * stride; + u32[record + recordHash] = hash; + u32[record + recordStart] = segment; + u32[record + recordLength] = length; + u32[record + recordCapacity] = capacityFor(length); + for (let w = 0; w < words; w++) u32[record + recordKey + w] = u32[keys + position * words + w]; + segments[bucket] = segment; + fills[bucket] = 0; + segment += capacityFor(length); + f64[ticks + id] = tick; + for (let k = 0; k < watched.length; k++) f64[(watched[k] >> 3) + id] = tick; + if (!placing) continue; + let slot = hash & mask; + while (slot < regionEnd && u32[slots + 2 * slot + 1] !== 0) slot++; + if (slot === regionEnd) i32[overflow + start + overflowed++] = id; + else { + u32[slots + 2 * slot] = hash; + u32[slots + 2 * slot + 1] = id + 1; + } + } + i32[(build.overflowed >> 2) + q] = overflowed; + for (let position = start; position < end; position++) { + const bucket = i32[local + position]; + const at = table + (segments[bucket] + fills[bucket]++) * entryWords; + const source = grouped + position * entryWords; + const entity = i32[source]; + i32[at] = entity; + i32[at + 1] = i32[source + 1]; + i32[at + 2] = i32[source + 2]; + i32[bucketOf + entity] = base + bucket; + } + } +}; diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts new file mode 100644 index 000000000..6ebbb8143 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; + +// An index lookup's `changed: true`: the index's entries changing is a change filter of its system. + +const group = Symbol("indexChangedTest/group"); +const weight = Symbol("indexChangedTest/weight"); +const total = Symbol("indexChangedTest/total"); +const paused = Symbol("indexChangedTest/paused"); + +declare module "@adobe/ecs" { + interface Components { + [group]: { type: number }; + [weight]: { type: number }; + [total]: { type: number }; + } + interface Resources { + [paused]: { type: boolean }; + } +} +Component.register(group, { type: { type: "integer", minimum: 0, maximum: 1000 } }); +Component.register(weight, { type: { type: "number" } }); +Component.register(total, { type: { type: "number" } }); +Resource.register(paused, { type: { type: "boolean", default: false } }); + +const byGroup = Index.create({ name: "indexChangedTest/byGroup", key: [group], components: { has: [weight] } }); +const inOrder = Index.create({ name: "indexChangedTest/inOrder", key: [group], sorted: true }); +const weighted = Index.create({ name: "indexChangedTest/weighted", key: [], components: { has: [weight] } }); + +const one = { type: "object", properties: { entity: Entity.schema, value: { type: "number" } } } as const; +const entityOnly = { type: "object", properties: { entity: Entity.schema } } as const; +let spawned: Entity[] = []; +const spawn = System.create({ name: "indexChangedTest/spawn", input: { type: "object", properties: { groups: { type: "array", items: { type: "integer" } } } } }, ({ input, commands }) => { + for (const value of input.groups) spawned.push(commands.spawn([group, weight, total])({ [group]: value, [weight]: 1, [total]: 0 })); +}); +const setGroup = System.create({ name: "indexChangedTest/setGroup", input: one, target: "entity", components: { write: [group] } }, (row, { input }) => { + row[group] = input.value; +}); +const setWeight = System.create({ name: "indexChangedTest/setWeight", input: one, target: "entity", components: { write: [weight] } }, (row, { input }) => { + row[weight] = input.value; +}); +const despawn = System.create({ name: "indexChangedTest/despawn", input: entityOnly, target: "entity", components: { has: [group] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); +const dropWeight = System.create({ name: "indexChangedTest/dropWeight", input: entityOnly, target: "entity", components: { has: [weight] } }, (row, { commands }) => { + commands.remove(row.entity, weight); +}); +const pause = System.create({ name: "indexChangedTest/pause", input: { type: "object", properties: { paused: { type: "boolean" } } }, resources: { write: [paused] } }, ({ input, resources }) => { + resources[paused] = input.paused; +}); + +/** What each run of a gated system saw: its lists' lengths and `changed` for groups 1 and 2. */ +let seen: string[] = []; +const counting = System.create({ name: "indexChangedTest/counting", lookup: { byGroup: { index: byGroup, changed: true } } }, ({ lookup }) => { + const first = lookup.byGroup(1); + const second = lookup.byGroup(2); + seen.push(`${first.length}${first.changed ? "*" : ""} ${second.length}${second.changed ? "*" : ""}`); +}); + +/** Each row's total: how many rows share its group, by hundreds, and its weight; run only when the groups changed. */ +const tally = System.create({ + name: "indexChangedTest/tally", kernel: "cpu", + components: { read: [group, weight], write: [total] }, + lookup: { byGroup: { index: byGroup, changed: true } }, +}, (row, { lookup }) => { + row[total] = lookup.byGroup(row[group]).length * 100 + row[weight]; +}); + +const inline = () => createWorld({ [workers]: 0 }); + +/** Runs `system`, then what `counting`-like systems saw, emptied. */ +const after = async (run: Promise): Promise => { + await run; + const found = seen; + seen = []; + return found; +}; + +describe("an index lookup's changed: true", () => { + it("runs its system on its first run, then only once an entity entered or left the index or moved to another key", async () => { + await using world = inline(); + spawned = []; + seen = []; + await world.run(spawn, { groups: [1, 1, 2] }); + const [a, b] = spawned; + expect(await after(world.run(counting))).toEqual(["2* 1*"]); + expect(await after(world.run(counting))).toEqual([]); + // What the index holds by doesn't change: a value it doesn't key on, or its key written the same. + await world.run(setWeight, { entity: a, value: 5 }); + await world.run(setGroup, { entity: a, value: 1 }); + expect(await after(world.run(counting))).toEqual([]); + // Moved to another key; its lists' changed reports which. + await world.run(setGroup, { entity: b, value: 2 }); + expect(await after(world.run(counting))).toEqual(["1* 2*"]); + await world.run(spawn, { groups: [3] }); + expect(await after(world.run(counting))).toEqual(["1 2"]); + await world.run(despawn, { entity: a }); + expect(await after(world.run(counting))).toEqual(["0* 2"]); + // Left the index, losing what it has. + await world.run(dropWeight, { entity: b }); + expect(await after(world.run(counting))).toEqual(["0 1*"]); + expect(await after(world.run(counting))).toEqual([]); + }); + + it("runs an observer system after the passes in which the index's entries changed, by position in a sorted one", async () => { + await using world = inline(); + spawned = []; + const orders: string[] = []; + const ordering = System.create({ name: "indexChangedTest/ordering", observer: true, lookup: { inOrder: { index: inOrder, changed: true } } }, ({ lookup }) => { + const all = lookup.inOrder(); + const groups: number[] = []; + for (let i = 0; i < all.length; i++) groups.push(all.at(i).entity === spawned[0] ? -1 : 0); + orders.push(groups.join(",")); + }); + await world.run(spawn, { groups: [1, 2] }); + world.add(ordering); + await world.run(ordering); + expect(orders).toEqual(["-1,0"]); + await world.run(setWeight, { entity: spawned[0], value: 3 }); + await world.run(setGroup, { entity: spawned[0], value: 1 }); + expect(orders).toEqual(["-1,0"]); + await world.run(setGroup, { entity: spawned[0], value: 5 }); + expect(orders).toEqual(["-1,0", "0,-1"]); + world.remove(ordering); + }); + + it("keeps its system's lastRun while skipped, as a failed condition does", async () => { + await using world = inline(); + spawned = []; + const flags: boolean[] = []; + const watching = System.create({ + name: "indexChangedTest/watching", + lookup: { byGroup: { index: byGroup, changed: true }, weights: { read: [weight], changed: [weight] } }, + }, ({ lookup }) => { + flags.push(lookup.weights(spawned[0])?.changed ?? false); + }); + await world.run(spawn, { groups: [1, 2] }); + await world.run(watching); + await world.run(setWeight, { entity: spawned[0], value: 7 }); + await world.run(watching); + expect(flags).toEqual([true]); + await world.run(setGroup, { entity: spawned[1], value: 3 }); + await world.run(watching); + // Since its last run, not the skipped one, the first entity's weight changed. + expect(flags).toEqual([true, true]); + }); + + it("passes every row when the index changed, and otherwise the rows its other change filters pass", async () => { + await using world = inline(); + spawned = []; + const rows: number[] = []; + const filtering = System.create({ + name: "indexChangedTest/filtering", + components: { read: [weight], changed: [weight] }, + lookup: { byGroup: { index: byGroup, changed: true } }, + }, row => { + rows.push(row[weight]); + }); + await world.run(spawn, { groups: [1, 2, 3] }); + await world.run(filtering); + expect(rows).toEqual([1, 1, 1]); + rows.length = 0; + await world.run(setWeight, { entity: spawned[1], value: 4 }); + await world.run(filtering); + expect(rows).toEqual([4]); + rows.length = 0; + await world.run(setGroup, { entity: spawned[2], value: 1 }); + await world.run(filtering); + expect(rows).toEqual([1, 4, 1]); + rows.length = 0; + await world.run(filtering); + expect(rows).toEqual([]); + }); + + it("runs when any of its indexes changed, and not while a condition fails, seeing the change once it holds", async () => { + await using world = inline(); + spawned = []; + let runs = 0; + const both = System.create({ + name: "indexChangedTest/both", + resources: { not: [paused] }, + lookup: { byGroup: { index: byGroup, changed: true }, weighted: { index: weighted, changed: true } }, + }, () => { + runs++; + }); + await world.run(spawn, { groups: [1, 2] }); + await world.run(both); + expect(runs).toBe(1); + // Only `weighted` changes: it holds every entity with weight, whatever its group. + await world.run(dropWeight, { entity: spawned[0] }); + await world.run(both); + expect(runs).toBe(2); + await world.run(pause, { paused: true }); + await world.run(setGroup, { entity: spawned[1], value: 3 }); + await world.run(both); + expect(runs).toBe(2); + await world.run(pause, { paused: false }); + await world.run(both); + expect(runs).toBe(3); + await world.run(both); + expect(runs).toBe(3); + }); + + it("skips a CPU kernel's run in which its index didn't change", async () => { + await using world = inline(); + spawned = []; + const totals: number[] = []; + const readTotals = System.create({ name: "indexChangedTest/readTotals", observer: true, components: { read: [total] } }, row => { + totals.push(row[total]); + }); + const totalsOf = async (): Promise => { + totals.length = 0; + await world.run(readTotals); + return [...totals]; + }; + await world.run(spawn, { groups: [1, 1, 2] }); + await world.run(tally); + expect(await totalsOf()).toEqual([201, 201, 101]); + await world.run(setWeight, { entity: spawned[0], value: 5 }); + await world.run(tally); + expect(await totalsOf()).toEqual([201, 201, 101]); + await world.run(setGroup, { entity: spawned[1], value: 2 }); + await world.run(tally); + expect(await totalsOf()).toEqual([105, 201, 201]); + }); + + it("is only for a system's index lookups", () => { + const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + const untypedFinalizer = (declaration: object): unknown => Reflect.apply(Finalizer.create, undefined, [declaration, () => {}]); + expect(() => untypedSystem({ name: "indexChangedTest/byEntity", lookup: { weights: { read: [weight], changed: true } } })) + .toThrow(`System "indexChangedTest/byEntity": lookup "weights"'s changed is true, which only an index lookup takes; name the components it watches.`); + expect(() => untypedFinalizer({ name: "indexChangedTest/finalizer", components: { read: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) + .toThrow(`Finalizer "indexChangedTest/finalizer": lookup "byGroup"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-hash.ts b/packages/ecs/src/implementation/world/index-hash.ts new file mode 100644 index 000000000..50ea59f3b --- /dev/null +++ b/packages/ecs/src/implementation/world/index-hash.ts @@ -0,0 +1,478 @@ +import { allocateArray, freeArray } from './arrays.js'; +import type { Memory } from './memory.js'; + +/** Words an entry takes: its entity index, its archetype's id and its row. */ +export const entryWords = 3; + +/** A bucket record's words: its key's hash, its segment's first entry, length and capacity, then its key's words. */ +export const recordHash = 0; +export const recordStart = 1; +export const recordLength = 2; +export const recordCapacity = 3; +export const recordKey = 4; + +/** A freed bucket's capacity. */ +const freedCapacity = 0xFFFFFFFF; + +/** + * A hash index: an open-addressing table of buckets by key, linear probing, at most half full, each slot a key's hash + * and its bucket, so a probe reads one line until a hash matches; each bucket a record of its hash, segment and key, + * its segment of one entries buffer with spare slots holding its entries in entity order. Every array is in the arena, + * so other threads read it where the world's thread wrote it. By entity index, the bucket it's filed in, so its old + * bucket is found without its old key. + */ +export type HashTable = { + readonly memory: Memory; + /** Words of a key, and of a bucket's record. */ + readonly words: number; + readonly stride: number; + /** By slot, two words: a bucket's hash and its id plus one, or 0 for none. */ + slots: Uint32Array; + /** By bucket id: its record, and the tick an entity last entered, left or moved in it. */ + records: Uint32Array; + ticks: Float64Array; + /** Bucket ids in use, the ones freed, and how many are empty but kept for their ticks. */ + count: number; + readonly freed: number[]; + empty: number; + /** The newest tick at which a bucket was dropped, which a key no bucket holds counts as changed at. */ + cleared: number; + /** Entries, `entryWords` each; the words segments use, and those moved segments left behind. */ + entries: Int32Array; + used: number; + garbage: number; + /** By entity index: its bucket, or -1. */ + bucketOf: Int32Array; + /** Entries held. */ + size: number; + /** By component a lookup watches: by bucket, the newest tick its value may have changed in a row there. */ + readonly watched: Map; +}; + +/** What lookups read of a hash table: on the world's thread the table, on a joined thread its views of it. */ +export type HashView = Pick & { readonly watched: ReadonlyMap }; + +const minimumSlots = 16; + +/** Slots the table keeps for each bucket at least: it's at most half full. */ +const slotsPerBucket = 2; + +/** Whether `slots` slots hold `buckets` buckets. */ +export const slotsHold = (slots: number, buckets: number): boolean => slotsPerBucket * buckets + 2 <= slots; + +export const createHashTable = (memory: Memory, words: number): HashTable => ({ + memory, + words, + stride: recordKey + words, + slots: allocateArray(memory, Uint32Array, 2 * minimumSlots).fill(0), + records: allocateArray(memory, Uint32Array, 8 * (recordKey + words)), + ticks: allocateArray(memory, Float64Array, 8), + count: 0, + freed: [], + empty: 0, + cleared: 0, + entries: allocateArray(memory, Int32Array, 64 * entryWords), + used: 0, + garbage: 0, + bucketOf: allocateArray(memory, Int32Array, 64).fill(-1), + size: 0, + watched: new Map(), +}); + +/** Frees every array, once nothing reads the table again. */ +export const releaseHashTable = (table: HashTable): void => { + const { memory } = table; + for (const array of [table.slots, table.records, table.ticks, table.entries, table.bucketOf, ...table.watched.values()]) freeArray(memory, array); + table.watched.clear(); +}; + +/** An array of `length` in the arena holding the first `used` elements of `array`, which is freed; the rest filled with `fill`. */ +const regrow = (memory: Memory, array: T, constructor: { new(buffer: ArrayBufferLike, byteOffset: number, length: number): T; readonly BYTES_PER_ELEMENT: number }, length: number, used: number, fill: number): T => { + const grown = allocateArray(memory, constructor, length); + grown.set(array.subarray(0, used)); + grown.fill(fill, used); + freeArray(memory, array); + return grown; +}; + +/** Grows what's by entity index to cover `length` indexes. */ +export const coverEntities = (table: HashTable, length: number): void => { + if (table.bucketOf.length >= length) return; + table.bucketOf = regrow(table.memory, table.bucketOf, Int32Array, Math.max(length, 2 * table.bucketOf.length), table.bucketOf.length, -1); +}; + +/** Grows what's by bucket id to hold `count` buckets. */ +export const coverBuckets = (table: HashTable, count: number): void => { + const capacity = table.ticks.length; + if (capacity >= count) return; + const length = Math.max(count, 2 * capacity); + const { memory, stride } = table; + table.records = regrow(memory, table.records, Uint32Array, length * stride, capacity * stride, 0); + table.ticks = regrow(memory, table.ticks, Float64Array, length, capacity, 0); + for (const [component, ticks] of table.watched) table.watched.set(component, regrow(memory, ticks, Float64Array, length, capacity, 0)); +}; + +/** A bucket's segment's first entry, length and capacity. */ +export const startOf = (table: HashView, bucket: number): number => table.records[bucket * table.stride + recordStart]; +export const lengthOf = (table: HashView, bucket: number): number => table.records[bucket * table.stride + recordLength]; +const capacityOf = (table: HashTable, bucket: number): number => table.records[bucket * table.stride + recordCapacity]; +const isFreed = (table: HashTable, bucket: number): boolean => capacityOf(table, bucket) === freedCapacity; + +/** Whether bucket `bucket`'s key is the `words` of `key` from `at`. */ +const sameKey = (table: HashView, bucket: number, key: Uint32Array, at: number): boolean => { + const { records, words } = table; + const base = bucket * table.stride + recordKey; + for (let k = 0; k < words; k++) if (records[base + k] !== key[at + k]) return false; + return true; +}; + +/** The bucket of a key, by its hash, or -1 when none holds it. */ +export const findBucket = (table: HashView, key: Uint32Array, at: number, hash: number): number => { + const { slots } = table; + const mask = (slots.length >> 1) - 1; + for (let slot = hash & mask; ; slot = (slot + 1) & mask) { + const found = slots[2 * slot + 1]; + if (found === 0) return -1; + if (slots[2 * slot] === hash && sameKey(table, found - 1, key, at)) return found - 1; + } +}; + +/** Places a bucket in the table by its hash; the table has a free slot. */ +export const place = (slots: Uint32Array, bucket: number, hash: number): void => { + const mask = (slots.length >> 1) - 1; + let slot = hash & mask; + while (slots[2 * slot + 1] !== 0) slot = (slot + 1) & mask; + slots[2 * slot] = hash; + slots[2 * slot + 1] = bucket + 1; +}; + +/** Empties the table at a capacity for `buckets`. */ +export const rehash = (table: HashTable, buckets: number): void => { + let capacity = minimumSlots; + while (!slotsHold(capacity, buckets)) capacity *= 2; + if (2 * capacity !== table.slots.length) { + freeArray(table.memory, table.slots); + table.slots = allocateArray(table.memory, Uint32Array, 2 * capacity); + } + table.slots.fill(0); +}; + +/** Places every bucket not freed in the table again. */ +export const placeAll = (table: HashTable): void => { + const { records, stride } = table; + for (let bucket = 0; bucket < table.count; bucket++) if (!isFreed(table, bucket)) place(table.slots, bucket, records[bucket * stride + recordHash]); +}; + +/** The bucket of a key, made empty when none holds it, with its tick at `tick`. */ +export const bucketFor = (table: HashTable, key: Uint32Array, at: number, hash: number, tick: number): number => { + const found = findBucket(table, key, at, hash); + if (found >= 0) return found; + const live = table.count - table.freed.length; + if (!slotsHold(table.slots.length >> 1, live + 1)) { + rehash(table, live + 1); + placeAll(table); + } + const bucket = table.freed.pop() ?? table.count++; + coverBuckets(table, table.count); + const { records, words, stride } = table; + const base = bucket * stride; + records[base + recordHash] = hash; + records[base + recordStart] = 0; + records[base + recordLength] = 0; + records[base + recordCapacity] = 0; + for (let k = 0; k < words; k++) records[base + recordKey + k] = key[at + k]; + table.ticks[bucket] = tick; + for (const ticks of table.watched.values()) ticks[bucket] = tick; + table.empty++; + place(table.slots, bucket, hash); + return bucket; +}; + +/** Spare slots a segment of `length` entries gets: an eighth, at least 4. */ +export const capacityFor = (length: number): number => length + Math.max(4, length >> 3); + +/** A segment of `capacity` entries at the end of the buffer, growing it as needed; returns its first entry. */ +const allocateSegment = (table: HashTable, capacity: number): number => { + const start = table.used; + const needed = (start + capacity) * entryWords; + if (needed > table.entries.length) { + table.entries = regrow(table.memory, table.entries, Int32Array, Math.max(needed, 2 * table.entries.length), table.used * entryWords, 0); + } + table.used += capacity; + return start; +}; + +/** Gives a bucket room for `length` entries, moving its segment to the end of the buffer when it doesn't fit. */ +const reserve = (table: HashTable, bucket: number, length: number): void => { + const base = bucket * table.stride; + if (table.records[base + recordCapacity] >= length) return; + const capacity = capacityFor(length); + const start = allocateSegment(table, capacity); + const { entries, records } = table; + const from = records[base + recordStart] * entryWords; + entries.copyWithin(start * entryWords, from, from + records[base + recordLength] * entryWords); + table.garbage += records[base + recordCapacity]; + records[base + recordStart] = start; + records[base + recordCapacity] = capacity; +}; + +/** + * Moves `words` of entries, a multiple of `entryWords`, from `from` to `to` within one buffer: by hand, an entry a step, + * when few, which a native call would cost more than. + */ +const move = (entries: Int32Array, to: number, from: number, words: number): void => { + if (words > 96) { + entries.copyWithin(to, from, from + words); + return; + } + if (to < from) { + for (let k = 0; k < words; k += 3) { + entries[to + k] = entries[from + k]; + entries[to + k + 1] = entries[from + k + 1]; + entries[to + k + 2] = entries[from + k + 2]; + } + } + else { + for (let k = words - 3; k >= 0; k -= 3) { + entries[to + k + 2] = entries[from + k + 2]; + entries[to + k + 1] = entries[from + k + 1]; + entries[to + k] = entries[from + k]; + } + } +}; + +/** Where entity index `index` is in a bucket, or where it would go, by binary search: `~position` when it isn't there. */ +export const positionIn = (table: HashTable, bucket: number, index: number): number => { + const { entries } = table; + const base = startOf(table, bucket); + let low = 0; + let high = lengthOf(table, bucket); + while (low < high) { + const middle = (low + high) >>> 1; + const at = entries[(base + middle) * entryWords]; + if (at < index) low = middle + 1; + else if (at > index) high = middle; + else return middle; + } + return ~low; +}; + +/** Updates where entity index `index` is: its archetype and row, in the bucket it's filed in. */ +export const relocate = (table: HashTable, bucket: number, index: number, archetype: number, row: number): void => { + const position = positionIn(table, bucket, index); + if (position < 0) return; + const at = (startOf(table, bucket) + position) * entryWords; + table.entries[at + 1] = archetype; + table.entries[at + 2] = row; +}; + +/** Notes that a bucket gained or lost its last entry. */ +const countEmpty = (table: HashTable, before: number, after: number): void => { + if (before === 0 && after > 0) table.empty--; + else if (before > 0 && after === 0) table.empty++; +}; + +/** Removes entity index `index` from a bucket, stamping it. */ +export const removeEntry = (table: HashTable, bucket: number, index: number, tick: number): void => { + const position = positionIn(table, bucket, index); + if (position < 0) return; + const record = bucket * table.stride; + const length = table.records[record + recordLength]; + const base = table.records[record + recordStart] * entryWords; + move(table.entries, base + position * entryWords, base + (position + 1) * entryWords, (length - position - 1) * entryWords); + table.records[record + recordLength] = length - 1; + table.ticks[bucket] = tick; + table.size--; + countEmpty(table, length, length - 1); +}; + +/** Inserts entity index `index` at its archetype and row into a bucket, in entity order, stamping it. */ +export const insertEntry = (table: HashTable, bucket: number, index: number, archetype: number, row: number, tick: number): void => { + const record = bucket * table.stride; + const length = table.records[record + recordLength]; + reserve(table, bucket, length + 1); + const found = positionIn(table, bucket, index); + const position = found < 0 ? ~found : found; + const base = table.records[record + recordStart] * entryWords; + const { entries } = table; + move(entries, base + (position + 1) * entryWords, base + position * entryWords, (length - position) * entryWords); + const at = base + position * entryWords; + entries[at] = index; + entries[at + 1] = archetype; + entries[at + 2] = row; + table.records[record + recordLength] = length + 1; + table.ticks[bucket] = tick; + table.size++; + countEmpty(table, length, length + 1); +}; + +/** + * Applies many removals and insertions to one bucket in one merge: `removed` entity indexes and `added` entries + * (`entryWords` each), both in entity order. Keeps the bucket's entries in entity order. + */ +export const mergeEntries = (table: HashTable, bucket: number, removed: Int32Array, removedCount: number, added: Int32Array, addedCount: number, scratch: Int32Array, tick: number): void => { + const record = bucket * table.stride; + const length = table.records[record + recordLength]; + const result = length - removedCount + addedCount; + const base = table.records[record + recordStart] * entryWords; + // The bucket's current entries, copied out, merged back with the insertions, skipping the removals. + scratch.set(table.entries.subarray(base, base + length * entryWords)); + reserve(table, bucket, result); + const target = table.entries; + let to = table.records[record + recordStart] * entryWords; + let r = 0; + let a = 0; + for (let k = 0; k < length; k++) { + const index = scratch[k * entryWords]; + while (r < removedCount && removed[r] < index) r++; + while (a < addedCount && added[a * entryWords] < index) { + target[to++] = added[a * entryWords]; + target[to++] = added[a * entryWords + 1]; + target[to++] = added[a * entryWords + 2]; + a++; + } + if (r < removedCount && removed[r] === index) { + r++; + continue; + } + target[to++] = index; + target[to++] = scratch[k * entryWords + 1]; + target[to++] = scratch[k * entryWords + 2]; + } + for (; a < addedCount; a++) { + target[to++] = added[a * entryWords]; + target[to++] = added[a * entryWords + 1]; + target[to++] = added[a * entryWords + 2]; + } + table.records[record + recordLength] = result; + table.ticks[bucket] = tick; + table.size += result - length; + countEmpty(table, length, result); +}; + +/** + * Drops the empty buckets once they outnumber those holding entries, compacting the segments when moves left more + * garbage than live entries. A key whose bucket was dropped counts as changed at `tick`. + */ +export const tidyHashTable = (table: HashTable, tick: number): void => { + const live = table.count - table.freed.length - table.empty; + if (table.empty > Math.max(64, live)) { + const { records, stride } = table; + for (let bucket = 0; bucket < table.count; bucket++) { + const base = bucket * stride; + if (records[base + recordCapacity] === freedCapacity || records[base + recordLength] > 0) continue; + table.garbage += records[base + recordCapacity]; + records[base + recordCapacity] = freedCapacity; + table.freed.push(bucket); + } + table.empty = 0; + table.cleared = tick; + rehash(table, live); + placeAll(table); + } + if (table.garbage > Math.max(1024, table.used - table.garbage)) compact(table); +}; + +/** Lays every bucket's segment out again, one after another, with their spare slots. */ +const compact = (table: HashTable): void => { + const { records, stride } = table; + let total = 0; + for (let bucket = 0; bucket < table.count; bucket++) if (!isFreed(table, bucket)) total += capacityFor(records[bucket * stride + recordLength]); + const old = table.entries; + const entries = allocateArray(table.memory, Int32Array, Math.max(64, total) * entryWords); + let used = 0; + for (let bucket = 0; bucket < table.count; bucket++) { + if (isFreed(table, bucket)) continue; + const base = bucket * stride; + const length = records[base + recordLength]; + const from = records[base + recordStart] * entryWords; + entries.set(old.subarray(from, from + length * entryWords), used * entryWords); + records[base + recordStart] = used; + records[base + recordCapacity] = capacityFor(length); + used += records[base + recordCapacity]; + } + freeArray(table.memory, old); + table.entries = entries; + table.used = used; + table.garbage = 0; +}; + +/** + * Builds the table again from `count` entries in entity order: entity indexes, archetypes and rows (`entryWords` each + * in `entries`), and their keys' words and hashes, with `bucketOfEntry` for scratch. Every bucket's tick, and every + * watched tick, is `tick`. + */ +export const loadHashTable = (table: HashTable, count: number, entries: Int32Array, keys: Uint32Array, hashes: Uint32Array, bucketOfEntry: Int32Array, tick: number, entityCount: number): void => { + const { words, stride } = table; + coverEntities(table, entityCount); + table.bucketOf.fill(-1); + // Sized for as many buckets as it last held, growing as more are found, so the table is as small as its keys need. + const expected = Math.min(count, Math.max(16, table.count - table.freed.length - table.empty)); + table.count = 0; + table.freed.length = 0; + table.empty = 0; + table.cleared = tick; + table.size = count; + rehash(table, expected); + coverBuckets(table, expected); + for (let k = 0; k < count; k++) { + const hash = hashes[k]; + let bucket = findBucket(table, keys, k * words, hash); + if (bucket < 0) { + if (!slotsHold(table.slots.length >> 1, table.count + 1)) { + rehash(table, 2 * (table.count + 1)); + placeAll(table); + } + bucket = table.count++; + coverBuckets(table, table.count); + const base = bucket * stride; + table.records[base + recordHash] = hash; + table.records[base + recordLength] = 0; + table.records[base + recordCapacity] = 0; + for (let w = 0; w < words; w++) table.records[base + recordKey + w] = keys[k * words + w]; + place(table.slots, bucket, hash); + } + table.records[bucket * stride + recordLength]++; + bucketOfEntry[k] = bucket; + } + const { records } = table; + let used = 0; + for (let bucket = 0; bucket < table.count; bucket++) { + const base = bucket * stride; + records[base + recordStart] = used; + records[base + recordCapacity] = capacityFor(records[base + recordLength]); + used += records[base + recordCapacity]; + records[base + recordLength] = 0; + table.ticks[bucket] = tick; + for (const ticks of table.watched.values()) ticks[bucket] = tick; + } + if (used * entryWords > table.entries.length) { + freeArray(table.memory, table.entries); + table.entries = allocateArray(table.memory, Int32Array, Math.max(64, used) * entryWords); + } + table.used = used; + table.garbage = 0; + const target = table.entries; + for (let k = 0; k < count; k++) { + const base = bucketOfEntry[k] * stride; + const at = (records[base + recordStart] + records[base + recordLength]++) * entryWords; + target[at] = entries[k * entryWords]; + target[at + 1] = entries[k * entryWords + 1]; + target[at + 2] = entries[k * entryWords + 2]; + table.bucketOf[entries[k * entryWords]] = bucketOfEntry[k]; + } +}; + +/** Starts keeping, by bucket, the newest tick a watched component may have changed in a row there, at `tick`. */ +export const watchHashComponent = (table: HashTable, component: number, tick: number): void => { + if (table.watched.has(component)) return; + table.watched.set(component, allocateArray(table.memory, Float64Array, table.ticks.length).fill(tick)); +}; + +/** Stops keeping a watched component's ticks. */ +export const unwatchHashComponent = (table: HashTable, component: number): void => { + const ticks = table.watched.get(component); + if (ticks === undefined) return; + freeArray(table.memory, ticks); + table.watched.delete(component); +}; diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts new file mode 100644 index 000000000..fbd257d70 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { backToBack } from "./loop.js"; +import { dropReports, runReported } from "./reports.test-helper.js"; + +// Systems on a joined thread looking up through the world's indexes, the world and the thread on this one, against a +// world running them itself. + +const cell = Symbol("indexJoinedTest/cell"); +const weight = Symbol("indexJoinedTest/weight"); +const serial = Symbol("indexJoinedTest/serial"); +const target = Symbol("indexJoinedTest/target"); +const lane = Symbol("indexJoinedTest/lane"); +const rank = Symbol("indexJoinedTest/rank"); +const label = Symbol("indexJoinedTest/label"); +const name = Symbol("indexJoinedTest/name"); +const title = Symbol("indexJoinedTest/title"); + +declare module "@adobe/ecs" { + export interface Threads { + indexJoinedTest: true; + } + interface Components { + [cell]: { type: readonly [number, number] }; + [weight]: { type: number }; + [serial]: { type: number }; + [target]: { type: number }; + [lane]: { type: number }; + [rank]: { type: number }; + [label]: { type: { readonly text: string }; thread: "indexJoinedTest" }; + [name]: { type: { readonly text: string }; thread: "indexJoinedTest" }; + [title]: { type: string }; + } +} +Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); +Component.register(weight, { type: { type: "number" } }); +Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); +Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); +Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); +Component.register(rank, { type: { type: "number" } }); +const text = { type: "object", properties: { text: { type: "string" } } } as const; +/** A label's start, which a command here gives it by this value. */ +const blank = { text: "" }; +Component.register(label, { type: { ...text, default: blank }, thread: "indexJoinedTest" }); +Component.register(name, { type: text, thread: "indexJoinedTest" }); +Component.register(title, { type: { type: "string" } }); + +const grid = Index.create({ name: "indexJoinedTest/grid", key: [cell], components: { has: [weight] } }); +const serials = Index.create({ name: "indexJoinedTest/serials", key: [serial], unique: true, components: { has: [weight] } }); +const lanes = Index.create({ name: "indexJoinedTest/lanes", key: [lane, rank], sorted: true }); +const names = Index.create({ name: "indexJoinedTest/names", key: [name] }); +const titles = Index.create({ name: "indexJoinedTest/titles", key: [title] }); +const weighted = Index.create({ name: "indexJoinedTest/weighted", key: [], components: { has: [weight] } }); + +/** On the joined thread, in the tier before the lookups, so a task of consecutive tiers there would see the index before it: moves some cells. */ +const shift = System.create({ name: "indexJoinedTest/shift", components: { read: [serial], write: [cell, label] } }, row => { + if (row[serial] % 17 !== 0) return; + const [x, y] = row[cell]; + row[cell] = [(x + 1) % 13, y]; + row[label] = { text: "" }; +}); + +/** On the joined thread: each cell's neighbors' count and weights, whether its list changed, and its serial's target's weight. */ +const describeCells = System.create({ + name: "indexJoinedTest/describeCells", + components: { read: [cell, target], write: [label] }, + lookup: { near: { index: grid, read: [weight], changed: [weight] }, bySerial: { index: serials, read: [weight] }, all: { index: weighted, read: [weight] } }, + after: [shift], +}, (row, { lookup }) => { + const [x, y] = row[cell]; + let found = 0; + let sum = 0; + for (let dx = -1; dx <= 1; dx++) { + const near = lookup.near([x + dx, y]); + for (let i = 0; i < near.length; i++) { + sum += near.at(i)[weight]; + found++; + } + } + const followed = lookup.bySerial(row[target]); + const all = lookup.all(); + let every = 0; + for (let i = row[target] % 11; i < all.length; i += 11) every += all.at(i)[weight] * (i + 1); + row[label] = { text: `${found}:${sum}:${lookup.near([x, y]).changed}:${followed === null ? "none" : followed[weight]}:${all.length}:${every}:${all.changed}` }; +}); + +/** On the joined thread: the rows of the row's lane within 5 of its rank, in key order. */ +const describeRanks = System.create({ + name: "indexJoinedTest/describeRanks", + components: { read: [lane, rank], write: [label] }, + lookup: { inLane: { index: lanes, read: [rank] } }, + after: [describeCells], +}, (row, { lookup }) => { + const r = row[rank]; + const near = lookup.inLane(row[lane], { ">=": r - 5, "<=": r + 5 }); + const ranks: number[] = []; + for (let i = 0; i < near.length; i++) ranks.push(near.at(i)[rank]); + row[label] = { text: ranks.join(",") }; +}); + +const tick = System.create({ name: "indexJoinedTest/tick", systems: [shift, describeCells, describeRanks] }); + +const seed = System.create({ name: "indexJoinedTest/seed" }, ({ commands }) => { + for (let k = 0; k < 200; k++) commands.spawn([cell, weight, serial, target, label])({ [cell]: [k % 13, k % 7], [weight]: k % 5, [serial]: k, [target]: (k * 7) % 230, [label]: blank }); + for (let k = 0; k < 150; k++) commands.spawn([lane, rank, label])({ [lane]: k % 3, [rank]: (k * 37) % 97, [label]: blank }); +}); + +/** Moves cells, changes weights and ranks, despawns some and spawns others. */ +const stir = System.create({ name: "indexJoinedTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { read: [serial], write: [cell, weight] } }, (row, { input, commands }) => { + const [x, y] = row[cell]; + if ((row[serial] + input.round) % 9 === 0) row[cell] = [(x + 1) % 13, y]; + if ((row[serial] + input.round) % 11 === 0) row[weight] = row[weight] + 1; + if ((row[serial] + input.round) % 23 === 0) commands.despawn(row.entity); + if (row[serial] % 50 === input.round) commands.spawn([cell, weight, serial, target, label])({ [cell]: [input.round, 3], [weight]: 2, [serial]: 1000 + row[serial], [target]: row[serial], [label]: blank }); +}); +const rerank = System.create({ name: "indexJoinedTest/rerank", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [rank] } }, (row, { input }) => { + if ((row[rank] + input.round) % 5 === 0) row[rank] = (row[rank] * 3 + 1) % 97; +}); + +/** A system of the joined thread looking up through an index of its strings, which the world keeps on its own thread. */ +const byName = System.create({ + name: "indexJoinedTest/byName", + components: { read: [name], write: [label] }, + lookup: { named: { index: names } }, +}, (row, { lookup }) => { + row[label] = { text: String(lookup.named(row[name]).length) }; +}); + +/** On the joined thread, added and removed: labels each cell by its bucket's length. */ +const counting = System.create({ + name: "indexJoinedTest/counting", + components: { read: [cell], write: [label] }, + lookup: { near: { index: grid, changed: [weight] } }, +}, (row, { lookup }) => { + const [x, y] = row[cell]; + row[label] = { text: String(lookup.near([x, y]).length) }; +}); +const keepTicking = System.create({ name: "indexJoinedTest/keepTicking" }, () => {}); + +const ranks = Index.create({ name: "indexJoinedTest/ranks", key: [rank], sorted: true }); +/** Systems of the joined thread writing a string, and looking up by strings, which only the world's thread does. */ +const writeTitle = System.create({ name: "indexJoinedTest/writeTitle", components: { read: [label], write: [title] } }, row => { + row[title] = row[label].text; +}); +const byTitle = System.create({ name: "indexJoinedTest/byTitle", components: { read: [title], write: [label] }, lookup: { titled: { index: titles } } }, (row, { lookup }) => { + row[label] = { text: String(lookup.titled(row[title]).length) }; +}); + +/** A system of the joined thread whose rows are in an index's order, which runs only on the world's thread. */ +const inOrder = System.create({ name: "indexJoinedTest/inOrder", components: { read: [rank], write: [label] }, order: ranks }, row => { + row[label] = { text: String(row[rank]) }; +}); + +/** Systems on the joined thread and an observer system of its objects, each with an index as a change filter, which run only on the world's thread. */ +const countWhenMoved = System.create({ name: "indexJoinedTest/countWhenMoved", components: { read: [cell], write: [label] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { + row[label] = { text: String(lookup.near(row[cell]).length) }; +}); +const readWhenMoved = System.create({ name: "indexJoinedTest/readWhenMoved", observer: true, components: { read: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { + void row[label]; +}); +const ownWhenMoved = System.create({ name: "indexJoinedTest/ownWhenMoved", observer: true, lookup: { near: { index: grid, changed: true } } }, () => {}); + +/** The entities `readLabels` reads, and their labels. */ +const reading: { entities: readonly Entity[]; labels: readonly string[] } = { entities: [], labels: [] }; +const readLabels = System.create({ name: "indexJoinedTest/readLabels", observer: true, lookup: { labeled: { read: [label] } } }, ({ lookup }) => { + reading.labels = reading.entities.map(entity => lookup.labeled(entity)?.[label].text ?? "missing"); +}); + +/** Each entity's label, sorted, as the world or the thread that fills its objects reads it, as of the last sync point. */ +const labels = async (holder: World, world: World): Promise => { + reading.entities = await Observe.toPromise(Observe.select(world, { required: [label] })); + await holder.run(readLabels); + return [...reading.labels].sort(); +}; + +/** Seeds, then ticks around changes; every label after each tick, and the warnings of each tick. */ +const scenario = async (world: CreatedWorld, holder: World): Promise<{ readonly labels: readonly (readonly string[])[]; readonly warnings: readonly (readonly string[])[] }> => { + const seen: (readonly string[])[] = []; + const warnings: (readonly string[])[] = []; + await world.run(seed); + for (let round = 0; round < 5; round++) { + const report = await runReported(world, tick); + expect(report.failures).toEqual([]); + warnings.push(report.warnings.map(warning => warning.name)); + await world.run(dropReports); + seen.push(await labels(holder, world)); + await world.run(stir, { round }); + await world.run(rerank, { round }); + } + return { labels: seen, warnings }; +}; + +/** Once the last thread shared has joined. */ +const joinedStatus = (world: CreatedWorld): Promise => new Promise(resolve => { + let done = false; + const stop = world.threads(threads => { + if (done || threads.at(-1)?.status !== "joined") return; + done = true; + queueMicrotask(() => stop()); + resolve(); + }); +}); + +describe("systems on a joined thread looking up through indexes", () => { + it("find what the world's own systems find: hash buckets, unique rows, sorted ranges and changes", async () => { + const inline = createWorld({ [workers]: 0 }); + const alone = await scenario(inline, inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + const there = await scenario(world, remote); + expect(there.labels).toEqual(alone.labels); + expect(alone.labels.flat().some(text => text.includes("true"))).toBe(true); + // The systems ran there, their tiers waiting for the thread. + expect(there.warnings.every(names => names.includes("indexJoinedTest/describeCells"))).toBe(true); + expect(alone.warnings.flat()).toEqual([]); + await remote.dispose(); + await world.dispose(); + }); + it("release the index and its records once the last system using it there is removed", async () => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + await world.run(seed); + /** Once `count` more ticks ran. */ + const ticks = async (count: number): Promise => { + const first = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await new Promise(resolve => { + const stop = Observe.resource(world, clock)(({ tick }) => { + if (tick < first + count) return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + }; + world.add(keepTicking); + // Ticks of what an index is keyed by and watches stay once started, as any component's do, and task blocks keep + // their size. + for (let round = 0; round < 2; round++) { + world.add(counting); + await ticks(2); + world.remove(counting); + await ticks(4); + } + const before = await world.tables(); + world.add(counting); + await ticks(2); + const added = await world.tables(); + expect(added.indexes).toBe(before.indexes + 1); + expect(added.arena).toBeGreaterThan(before.arena); + world.remove(counting); + await ticks(4); + const removed = await world.tables(); + expect(removed.indexes).toBe(before.indexes); + expect(removed.arena).toBe(before.arena); + expect(removed.joined).toEqual(before.joined); + driver.close(); + await remote.dispose(); + await world.dispose(); + }); + it("throw for an index with an object or string key part, which the world keeps on its own thread, for an order, for a string written, and for an index as a change filter", async () => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + await expect(world.run(byName)).rejects.toThrow('looks up through index "indexJoinedTest/names", whose key part "indexJoinedTest/name" is an object, so it runs only on the world\'s thread.'); + await expect(world.run(inOrder)).rejects.toThrow("orders its rows by an index, which runs only on the world's thread"); + await expect(world.run(writeTitle)).rejects.toThrow(`System "indexJoinedTest/writeTitle" runs on thread "indexJoinedTest", which a joined thread fills, and writes "indexJoinedTest/title", a string, which only the world's thread writes.`); + await expect(world.run(byTitle)).rejects.toThrow('looks up through index "indexJoinedTest/titles", whose key part "indexJoinedTest/title" is a string, which the world\'s thread keeps keys of, so it runs only on the world\'s thread.'); + const moved = `and when index "indexJoinedTest/grid" changed, a lookup's changed being true, which runs only on the world's thread.`; + await expect(world.run(countWhenMoved)).rejects.toThrow(`System "indexJoinedTest/countWhenMoved" runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); + await expect(world.run(readWhenMoved)).rejects.toThrow(`System "indexJoinedTest/readWhenMoved" runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); + expect(() => remote.add(ownWhenMoved)).toThrow(`Observer system "indexJoinedTest/ownWhenMoved" runs when index "indexJoinedTest/grid" changed, as its lookup "near"'s changed is true, so it runs only on the world's thread.`); + await remote.dispose(); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-kernels.test.ts b/packages/ecs/src/implementation/world/index-kernels.test.ts new file mode 100644 index 000000000..28cae0656 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-kernels.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { Observe } from "../../observe/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "./create.js"; + +const cell = Symbol("indexKernelsTest/cell"); +const weight = Symbol("indexKernelsTest/weight"); +const total = Symbol("indexKernelsTest/total"); +const count = Symbol("indexKernelsTest/count"); +const lane = Symbol("indexKernelsTest/lane"); +const rank = Symbol("indexKernelsTest/rank"); +const serial = Symbol("indexKernelsTest/serial"); +const target = Symbol("indexKernelsTest/target"); +const seen = Symbol("indexKernelsTest/seen"); +const changes = Symbol("indexKernelsTest/changes"); +const ranked = Symbol("indexKernelsTest/ranked"); + +declare module "@adobe/ecs" { + interface Components { + [cell]: { type: readonly [number, number] }; + [weight]: { type: number }; + [total]: { type: number }; + [count]: { type: number }; + [lane]: { type: number }; + [rank]: { type: number }; + [serial]: { type: number }; + [target]: { type: number }; + [seen]: { type: Entity }; + [changes]: { type: number }; + [ranked]: { type: true }; + } +} +Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); +Component.register(weight, { type: { type: "number", precision: 1 } }); +Component.register(total, { type: { type: "number" } }); +Component.register(count, { type: { type: "number" } }); +Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); +Component.register(rank, { type: { type: "number" } }); +Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); +Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); +Component.register(seen, { type: Entity.schema }); +Component.register(changes, { type: { type: "number" } }); +Component.register(ranked, { type: { const: true } }); + +const grid = Index.create({ name: "indexKernelsTest/grid", key: [cell], components: { has: [weight] } }); +const lanes = Index.create({ name: "indexKernelsTest/lanes", key: [lane, rank], sorted: true, components: { has: [ranked] } }); +const serials = Index.create({ name: "indexKernelsTest/serials", key: [serial], unique: true, components: { has: [weight] } }); + +/** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ +const neighbors = System.create({ + name: "indexKernelsTest/neighbors", kernel: "cpu", + components: { read: [cell], write: [total, count] }, + lookup: { near: { index: grid, read: [weight] } }, +}, (row, { lookup }) => { + const [x, y] = row[cell]; + let sum = 0; + let found = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const near = lookup.near([x + dx, y + dy]); + for (let i = 0; i < near.length; i++) { + sum += near.at(i)[weight]; + found++; + } + } + } + row[total] = sum; + row[count] = found; +}); + +/** The same, on the main thread, for comparison. */ +const neighborsByHand = System.create({ name: "indexKernelsTest/neighborsByHand", components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { + const [x, y] = row[cell]; + let sum = 0; + let found = 0; + for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { + const near = lookup.near([x + dx, y + dy]); + for (let i = 0; i < near.length; i++) { + sum += near.at(i)[weight]; + found++; + } + } + row[total] = sum; + row[count] = found; +}); + +/** How many rows of the row's lane have a rank within 5 of its own, and the first of them, through a sorted range. */ +const window = System.create({ + name: "indexKernelsTest/window", kernel: "cpu", + components: { read: [lane, rank], write: [count, seen] }, + lookup: { inLane: { index: lanes } }, +}, (row, { lookup }) => { + const r = row[rank]; + const near = lookup.inLane(row[lane], { ">=": r - 5, "<": r + 5 }); + row[count] = near.length; + row[seen] = near.at(0).entity; +}); + +const windowByHand = System.create({ name: "indexKernelsTest/windowByHand", components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } } }, (row, { lookup }) => { + const r = row[rank]; + const near = lookup.inLane(row[lane], { ">=": r - 5, "<": r + 5 }); + row[count] = near.length; + row[seen] = near.at(0).entity; +}); + +/** The weight of the entity whose serial is the row's target, or -1. */ +const follow = System.create({ + name: "indexKernelsTest/follow", kernel: "cpu", + components: { read: [target], write: [total] }, + lookup: { bySerial: { index: serials, read: [weight] } }, +}, (row, { lookup }) => { + const found = lookup.bySerial(row[target]); + row[total] = found === null ? -1 : found[weight]; +}); + +/** Counts runs whose list of the row's cell changed. */ +const watch = System.create({ + name: "indexKernelsTest/watch", kernel: "cpu", + components: { read: [cell], write: [changes] }, + lookup: { near: { index: grid, changed: [weight] } }, +}, (row, { lookup }) => { + if (lookup.near([row[cell][0], row[cell][1]]).changed) row[changes] = row[changes] + 1; +}); + +const spawnGrid = System.create({ name: "indexKernelsTest/spawnGrid", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = 0; k < input.count; k++) commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [(k * 7) % 5, (k * 3) % 4], [weight]: k % 9, [total]: 0, [count]: 0, [changes]: 0, [serial]: k }); +}); + +const spawnLanes = System.create({ name: "indexKernelsTest/spawnLanes", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = 0; k < input.count; k++) commands.spawn([lane, rank, ranked, count, seen])({ [lane]: k % 3, [rank]: (k * 37) % 101, [ranked]: true, [count]: 0, [seen]: Entity.none }); +}); + +const read = async (world: CreatedWorld, required: readonly Component.Key[], keys: readonly Component.Key[]): Promise => { + const entities = await Observe.toPromise(Observe.select(world, { required: [...required] })); + // Entities of this world hold these components, by their schemas. + const rows = await Promise.all(entities.map(entity => Observe.toPromise(Observe.entity(world, entity, { required: [...keys] })) as Promise<{ readonly [key: symbol]: unknown } | null>)); + return rows.map(row => keys.map(key => row?.[key])); +}; + +describe.each([[true], [false]])("kernels looking up through indexes, WASM %s", wasm => { + it("find a hash index's buckets by a literal tuple key, as a main-thread system does", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(spawnGrid, { count: 60 }); + await world.run(neighbors); + const kernel = await read(world, [cell], [total, count]); + await world.run(neighborsByHand); + expect(kernel).toEqual(await read(world, [cell], [total, count])); + expect(kernel.some(([, found]) => Number(found) > 1)).toBe(true); + }); + it("find a sorted index's range of a prefix and a comparison, in key order, with each row's entity", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(spawnLanes, { count: 400 }); + await world.run(window); + const kernel = await read(world, [ranked], [count, seen]); + await world.run(windowByHand); + expect(kernel).toEqual(await read(world, [ranked], [count, seen])); + expect(kernel.some(([found]) => Number(found) > 3)).toBe(true); + }); + it("give a unique index's row or null", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(spawnGrid, { count: 10 }); + await world.run(System.create(({ commands }) => { + commands.spawn([target, total])({ [target]: 3, [total]: 0 }); + commands.spawn([target, total])({ [target]: 99, [total]: 0 }); + })); + await world.run(follow); + expect((await read(world, [target], [total])).map(([value]) => value)).toEqual([3, -1]); + }); + it("report a list changed only when an entity entered, left or moved in it or a watched value changed", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(spawnGrid, { count: 20 }); + await world.run(watch); + await world.run(watch); + const counted = async (): Promise => (await read(world, [cell], [changes])).reduce((sum, [value]) => sum + Number(value), 0); + expect(await counted()).toBe(20); + const entities = await Observe.toPromise(Observe.select(world, { required: [cell] })); + await world.run(System.create(({ commands }) => { commands.add(entities[0], weight, 100); })); + await world.run(watch); + // The rows of the changed entity's cell saw it change. + expect(await counted()).toBeGreaterThan(20); + }); +}); + +/** A comparison on a hash index. */ +const compared = System.create({ + name: "indexKernelsTest/compared", kernel: "cpu", + components: { read: [serial], write: [count] }, + lookup: { bySerial: { index: serials } }, +}, (row, { lookup }) => { + // @ts-expect-error a hash index takes whole keys + row[count] = lookup.bySerial({ "<": row[serial] }) === null ? 0 : 1; +}); + +describe("kernels looking up through indexes, at registration", () => { + it("throw for a comparison on a hash index", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm: true }); + await world.run(spawnGrid, { count: 4 }); + await expect(world.run(compared)).rejects.toThrow(/sorted/); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-keyless.test.ts b/packages/ecs/src/implementation/world/index-keyless.test.ts new file mode 100644 index 000000000..f09fa6eb5 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-keyless.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import { select } from "./reads.test-helper.js"; + +// Indexes without a key: membership by `has` and `not`, in entity order, and its changes. + +const weight = Symbol("indexKeylessTest/weight"); +const hidden = Symbol("indexKeylessTest/hidden"); +const color = Symbol("indexKeylessTest/color"); +const spin = Symbol("indexKeylessTest/spin"); +const total = Symbol("indexKeylessTest/total"); +const count = Symbol("indexKeylessTest/count"); +const visited = Symbol("indexKeylessTest/visited"); + +declare module "@adobe/ecs" { + interface Components { + [weight]: { type: number }; + [hidden]: { type: true }; + [color]: { type: number }; + [spin]: { type: number }; + [total]: { type: number }; + [count]: { type: number }; + [visited]: { type: number }; + } +} +Component.register(weight, { type: { type: "number" } }); +Component.register(hidden, { type: { const: true } }); +Component.register(color, { type: { type: "number" } }); +Component.register(spin, { type: { type: "number" } }); +Component.register(total, { type: { type: "number" } }); +Component.register(count, { type: { type: "number" } }); +Component.register(visited, { type: { type: "number" } }); + +const shown = Index.create({ name: "indexKeylessTest/shown", key: [], components: { has: [weight], not: [hidden] } }); +const everything = Index.create({ name: "indexKeylessTest/everything", key: [] }); +const inOrder = Index.create({ name: "indexKeylessTest/inOrder", key: [], sorted: true, components: { has: [weight] } }); + +/** What a lookup gave: its entities and weights, in its order, its length and whether it changed. */ +type Seen = { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly length: number; readonly changed: boolean }; +let seen: Seen = { entities: [], weights: [], length: 0, changed: false }; +let all: readonly Entity[] = []; + +const look = System.create({ + name: "indexKeylessTest/look", + lookup: { shown: { index: shown, read: [weight] }, all: { index: everything } }, +}, ({ lookup }) => { + const rows = lookup.shown(); + const entities: Entity[] = []; + const weights: number[] = []; + for (let i = 0; i < rows.length; i++) { + entities.push(rows.at(i).entity); + weights.push(rows.at(i)[weight]); + } + seen = { entities, weights, length: rows.length, changed: rows.changed }; + const every = lookup.all(); + all = Array.from({ length: every.length }, (_, i) => every.at(i).entity); +}); + +/** Whether the lookup through `shown` changed, with `color` watched. */ +let watchedChanged = false; +const watchColor = System.create({ name: "indexKeylessTest/watchColor", lookup: { shown: { index: shown, changed: [weight] } } }, ({ lookup }) => { + watchedChanged = lookup.shown().changed; +}); + +const byIndex = (a: Entity, b: Entity): number => (a >>> 0) - (b >>> 0); + +/** The entities a query gives, in entity order, with their weights. */ +const expected = async (world: CreatedWorld): Promise<{ readonly entities: readonly Entity[]; readonly all: readonly Entity[] }> => ({ + entities: [...await select(world, { required: [weight], not: [hidden] })].sort(byIndex), + all: [...await select(world, {})].sort(byIndex), +}); + +const spawn = System.create({ name: "indexKeylessTest/spawn", input: { type: "object", properties: { weights: { type: "array", items: { type: "number" } } } } }, ({ input, commands }) => { + for (const value of input.weights) commands.spawn([weight, color])({ [weight]: value, [color]: 0 }); +}); + +describe("an index without a key on the main thread", () => { + it("holds the entities with its has and none of its not, in entity order, following spawns, despawns and moves", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn, { weights: [1, 2, 3, 4, 5, 6] }); + await world.run(System.create(({ commands }) => { + commands.spawn([color])({ [color]: 1 }); + commands.spawn([])({}); + })); + await world.run(look); + expect(seen.entities).toEqual((await expected(world)).entities); + expect(seen.weights).toEqual([1, 2, 3, 4, 5, 6]); + expect(seen.length).toBe(6); + // Without has or not, every entity but the resource entity, those with no components included. + expect(all).toEqual((await expected(world)).all); + expect(all.length).toBe(8); + const [first, second, third] = seen.entities; + // Hiding leaves, despawning leaves and moves the last row into its place, adding a component moves archetype. + await world.run(System.create(({ commands }) => { + commands.add(first, hidden); + commands.despawn(second); + commands.add(third, spin, 1); + })); + await world.run(look); + expect(seen.entities).toEqual((await expected(world)).entities); + expect(seen.weights).toEqual([3, 4, 5, 6]); + expect(all).toEqual((await expected(world)).all); + await world.run(System.create(({ commands }) => { + commands.remove(first, hidden); + commands.remove(third, weight); + })); + await world.run(look); + expect(seen.entities).toEqual((await expected(world)).entities); + expect(seen.weights).toEqual([1, 4, 5, 6]); + }); + it("reports changed only when an entity entered or left, or a watched component changed, since the system last ran", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn, { weights: [1, 2, 3] }); + await world.run(look); + expect(seen.changed).toBe(true); + await world.run(look); + expect(seen.changed).toBe(false); + // A write to what it holds by, unwatched, or to anything else, changes nothing. + await world.run(System.create({ components: { write: [weight, color] } }, row => { + row[weight] = row[weight] + 1; + row[color] = 2; + })); + await world.run(look); + expect(seen.changed).toBe(false); + // An entity moving to another archetype it holds keeps its place, and an entity it doesn't hold isn't its. + const entities = seen.entities; + await world.run(System.create(({ commands }) => { commands.add(entities[0], spin, 1); })); + await world.run(look); + expect(seen.changed).toBe(false); + await world.run(System.create(({ commands }) => { commands.spawn([color])({ [color]: 3 }); })); + await world.run(look); + expect(seen.changed).toBe(false); + await world.run(System.create(({ commands }) => { commands.add(entities[1], hidden); })); + await world.run(look); + expect(seen.changed).toBe(true); + // A watched component written in one of its rows. + await world.run(watchColor); + await world.run(watchColor); + expect(watchedChanged).toBe(false); + await world.run(System.create({ components: { write: [color] } }, row => { row[color] = 5; })); + await world.run(watchColor); + expect(watchedChanged).toBe(false); + await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = 5; })); + await world.run(watchColor); + expect(watchedChanged).toBe(true); + await world.run(watchColor); + expect(watchedChanged).toBe(false); + }); + it("runs a system's rows in entity order as a sorted index's order", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn, { weights: [5, 4, 3, 2, 1] }); + const entities = (await expected(world)).entities; + // Moving the first to another archetype puts it last in storage order. + await world.run(System.create(({ commands }) => { commands.add(entities[0], spin, 1); })); + const order: Entity[] = []; + await world.run(System.create({ components: { read: [weight] }, order: inOrder }, row => { if (row[weight] > 0) order.push(row.entity); })); + expect(order).toEqual(entities); + }); +}); + +/** A small random generator, the same for a seed. */ +const random = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +}; + +describe("an incrementally refreshed index without a key", () => { + it("holds what a query finds after random spawns, despawns and moves, patched or, past what a patch takes, built again", async () => { + await using world = createWorld({ [workers]: 0 }); + const next = random(9876); + const pick = (list: readonly T[]): T | undefined => list[Math.floor(next() * list.length)]; + let held = 0; + for (let step = 0; step < 80; step++) { + const living = await select(world, {}); + // Now and then more entities enter and leave than a patch takes. + const operations = next() < 0.1 ? 6000 : Math.floor(next() * 120); + const plan: (readonly [string, Entity | undefined, number])[] = []; + for (let k = 0; k < operations; k++) plan.push([pick(["spawn", "spawn", "spawn", "bare", "despawn", "despawn", "hide", "show", "weigh", "unweigh", "spin"]) ?? "spawn", pick(living), Math.floor(next() * 1000)]); + await world.run(System.create(({ commands }) => { + for (const [operation, entity, value] of plan) { + if (operation === "spawn") commands.spawn([weight, color])({ [weight]: value, [color]: 0 }); + else if (operation === "bare") commands.spawn([color])({ [color]: value }); + else if (entity === undefined) continue; + else if (operation === "despawn") commands.despawn(entity); + else if (operation === "hide") commands.add(entity, hidden); + else if (operation === "show") commands.remove(entity, hidden); + else if (operation === "weigh") commands.add(entity, weight, value); + else if (operation === "unweigh") commands.remove(entity, weight); + else commands.add(entity, spin, value); + } + })); + await world.run(look); + const found = await expected(world); + expect(seen.entities).toEqual(found.entities); + expect(all).toEqual(found.all); + held = Math.max(held, seen.length); + } + expect(held).toBeGreaterThan(1000); + }); +}); + +/** Each row's tally of the rows of `shown`: how many, and the sum of a window of their weights. */ +const tally = System.create({ + name: "indexKeylessTest/tally", kernel: "cpu", + components: { read: [color], write: [total, count] }, + lookup: { shown: { index: shown, read: [weight] } }, +}, (row, { lookup }) => { + const rows = lookup.shown(); + let sum = 0; + for (let i = row[color] % 7; i < rows.length; i += 7) sum += rows.at(i)[weight]; + row[total] = sum; + row[count] = rows.length; +}); +const tallyByHand = System.create({ name: "indexKeylessTest/tallyByHand", components: { read: [color], write: [total, count] }, lookup: { shown: { index: shown, read: [weight] } } }, (row, { lookup }) => { + const rows = lookup.shown(); + let sum = 0; + for (let i = row[color] % 7; i < rows.length; i += 7) sum += rows.at(i)[weight]; + row[total] = sum; + row[count] = rows.length; +}); +/** Counts runs whose list changed, `weight` watched. */ +const visit = System.create({ + name: "indexKeylessTest/visit", kernel: "cpu", + components: { write: [visited] }, + lookup: { shown: { index: shown, changed: [weight] } }, +}, (row, { lookup }) => { + if (lookup.shown().changed) row[visited] = row[visited] + 1; +}); + +const values = async (world: CreatedWorld, keys: readonly Component.Key[]): Promise => { + let found: readonly unknown[] = []; + const entities = [...await select(world, { required: [total] })].sort(byIndex); + await world.run(System.create({ lookup: { of: { read: [total, count, visited] } } }, ({ lookup }) => { + found = entities.map(entity => { + const row = lookup.of(entity); + return row === null ? null : keys.map(key => key === total ? row[total] : key === count ? row[count] : row[visited]); + }); + })); + return found; +}; + +describe.each([[true], [false]])("kernels looking up through an index without a key, WASM %s", wasm => { + it("find its rows, in entity order, and their changes, as a main-thread system does", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(System.create(({ commands }) => { + for (let k = 0; k < 300; k++) commands.spawn([weight, color, total, count, visited])({ [weight]: k % 11, [color]: k, [total]: 0, [count]: 0, [visited]: 0 }); + for (let k = 0; k < 40; k++) commands.spawn([color, total, count, visited])({ [color]: k, [total]: 0, [count]: 0, [visited]: 0 }); + })); + await world.run(System.create({ components: { read: [color] } }, (row, { commands }) => { + if (row[color] % 5 === 0) commands.add(row.entity, hidden); + if (row[color] % 9 === 0) commands.despawn(row.entity); + })); + await world.run(tally); + const kernel = await values(world, [total, count]); + await world.run(tallyByHand); + expect(kernel).toEqual(await values(world, [total, count])); + expect(kernel.some(row => Array.isArray(row) && Number(row[0]) > 0)).toBe(true); + await world.run(visit); + await world.run(visit); + const visits = async (): Promise => (await values(world, [visited])).reduce((sum, row) => sum + (Array.isArray(row) ? Number(row[0]) : 0), 0); + const rows = (await values(world, [visited])).length; + expect(await visits()).toBe(rows); + await world.run(System.create({ components: { write: [color] } }, row => { row[color] = row[color] + 1; })); + await world.run(visit); + expect(await visits()).toBe(rows); + await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = row[weight] + 1; })); + await world.run(visit); + expect(await visits()).toBe(2 * rows); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-keys.ts b/packages/ecs/src/implementation/world/index-keys.ts new file mode 100644 index 000000000..f75c136f6 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-keys.ts @@ -0,0 +1,271 @@ +import { declarationOf } from '../../interface/registry/index.js'; +import { layoutOf, type Layout, type Leaf, type Scalar } from '../../interface/registry/layout.js'; +import type { Storage } from './archetype.js'; + +/** + * How an index encodes its key into 32-bit words that compare and hash as values do: each struct leaf as one word, two + * for an f64, in an order-preserving form, so unsigned lexicographic word order is key order; an object part, or a + * string as the world's thread holds it, as the id its value is interned under; a constant part as nothing. + */ +export type KeyPart = + | { readonly kind: "leaves"; readonly leaves: readonly Leaf[]; readonly words: number; readonly first: number } + | { readonly kind: "object"; readonly first: number; readonly interned: Interned } + | { readonly kind: "constant"; readonly first: number }; + +/** Values of one object part by the id they're interned under, with how many entries hold each. */ +export type Interned = { + readonly ids: Map; + readonly values: unknown[]; + readonly counts: number[]; + readonly free: number[]; +}; + +const createInterned = (): Interned => ({ ids: new Map(), values: [], counts: [], free: [] }); + +/** Words a scalar takes. */ +const wordsOf = (scalar: Scalar): number => scalar === "f64" ? 2 : 1; + +/** Each part of a key, by its layout, with the word it starts at; and the key's words in all. */ +export const keyPartsOf = (layouts: readonly Layout[]): { readonly parts: readonly KeyPart[]; readonly words: number } => { + let words = 0; + const parts = layouts.map((layout): KeyPart => { + const first = words; + if (layout.kind === "constant") return { kind: "constant", first }; + if (layout.kind === "object" || layout.kind === "string") { + words++; + return { kind: "object", first, interned: createInterned() }; + } + let count = 0; + for (const leaf of layout.leaves) count += wordsOf(leaf.scalar); + words += count; + return { kind: "leaves", leaves: layout.leaves, words: count, first }; + }); + return { parts, words }; +}; + +/** An index's first key part whose keys only the world's thread keeps, by their values: an object or a string. */ +export const unsharedKeyOf = (key: readonly symbol[]): { readonly key: symbol; readonly what: string } | undefined => { + for (const part of key) { + const { kind } = layoutOf(declarationOf(part)?.type); + if (kind === "object") return { key: part, what: "an object" }; + if (kind === "string") return { key: part, what: "a string, which the world's thread keeps keys of" }; + } + return undefined; +}; + +/** Words a part takes. */ +export const partWords = (part: KeyPart): number => part.kind === "leaves" ? part.words : part.kind === "object" ? 1 : 0; + +/** NaN's encoding, after every other value. */ +const nan = 0xFFFFFFFF; + +/** An f32's bits in order-preserving form: -0 as +0, every NaN last. */ +export const orderF32 = (bits: number): number => { + if ((bits & 0x7F800000) === 0x7F800000 && (bits & 0x7FFFFF) !== 0) return nan; + if (bits === 0x80000000) return 0x80000000; + return (bits & 0x80000000) !== 0 ? ~bits >>> 0 : (bits | 0x80000000) >>> 0; +}; + +/** An f64's high and low words in order-preserving form, into `out[at]` and `out[at + 1]`. */ +export const orderF64 = (high: number, low: number, out: Uint32Array, at: number): void => { + if ((high & 0x7FF00000) === 0x7FF00000 && ((high & 0xFFFFF) | low) !== 0) { + out[at] = nan; + out[at + 1] = nan; + return; + } + if (high === 0x80000000 && low === 0) { + out[at] = 0x80000000; + out[at + 1] = 0; + return; + } + if ((high & 0x80000000) !== 0) { + out[at] = ~high >>> 0; + out[at + 1] = ~low >>> 0; + return; + } + out[at] = (high | 0x80000000) >>> 0; + out[at + 1] = low >>> 0; +}; + +/** A scalar's encoding of an i32's bits, a u32 or a boolean. */ +const orderI32 = (bits: number): number => (bits ^ 0x80000000) >>> 0; + +/** + * Writes a row's key part, read from its storage, into `out` from `at`: leaves straight from the column's words, an + * object part as its value's interned id, interning it with `intern` when given, or the id of none when it isn't. + */ +export const encodeStored = (part: KeyPart, storage: Storage, row: number, out: Uint32Array, at: number, intern: boolean): void => { + if (part.kind === "constant") return; + if (part.kind === "object") { + out[at] = intern ? internValue(part.interned, storage.values[row]) : idOf(part.interned, storage.values[row]); + return; + } + const { views } = storage; + const { leaves } = part; + let word = at; + for (let k = 0; k < leaves.length; k++) { + const leaf = leaves[k]; + const index = row * leaf.unitStride + leaf.unitOffset; + switch (leaf.scalar) { + case "f32": out[word++] = orderF32(views.u32[index]); break; + case "i32": out[word++] = orderI32(views.u32[index]); break; + case "u32": out[word++] = views.u32[index]; break; + case "boolean": out[word++] = views.u8[index] === 0 ? 0 : 1; break; + default: { + // An f64's unit is 8 bytes: its words are the u32 view's at twice its index, low first. + const low = views.u32[2 * index]; + const high = views.u32[2 * index + 1]; + orderF64(high, low, out, word); + word += 2; + } + } + } +}; + +/** Scratch for converting a number to a leaf's stored type and reading its bits. */ +const scratch = new ArrayBuffer(8); +const f32 = new Float32Array(scratch); +const f64 = new Float64Array(scratch); +const i32 = new Int32Array(scratch); +const u32 = new Uint32Array(scratch); +const u8 = new Uint8Array(scratch); + +/** A leaf's value converted to its stored type, as a write stores it, encoded into `out` from `at`; returns the next word. */ +export const encodeScalar = (scalar: Scalar, value: unknown, out: Uint32Array, at: number): number => { + const number = Number(value); + switch (scalar) { + case "f32": + f32[0] = number; + out[at] = orderF32(u32[0]); + return at + 1; + case "i32": + i32[0] = number; + out[at] = orderI32(u32[0]); + return at + 1; + case "u32": + u32[0] = number; + out[at] = u32[0]; + return at + 1; + case "boolean": + u8[0] = number; + out[at] = u8[0] === 0 ? 0 : 1; + return at + 1; + default: + f64[0] = number; + orderF64(u32[1], u32[0], out, at); + return at + 2; + } +}; + +const element = (value: unknown, key: number | string): unknown => typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; + +/** The scalar at a leaf's path in a value. */ +const leafValue = (value: unknown, leaf: Leaf): unknown => { + let found = value; + for (let k = 0; k < leaf.path.length; k++) found = element(found, leaf.path[k]); + return found; +}; + +/** + * Writes a key part given as a value, as a lookup's argument gives it, into `out` from `at`: converted to its leaves' + * stored types, and an object part as its value's interned id, or `missing` for a value nothing holds. + */ +export const encodeValue = (part: KeyPart, value: unknown, out: Uint32Array, at: number): void => { + if (part.kind === "constant") return; + if (part.kind === "object") { + out[at] = idOf(part.interned, value); + return; + } + const { leaves } = part; + if (leaves.length === 1 && leaves[0].path.length === 0) { + encodeScalar(leaves[0].scalar, value, out, at); + return; + } + let word = at; + for (let k = 0; k < leaves.length; k++) word = encodeScalar(leaves[k].scalar, leafValue(value, leaves[k]), out, word); +}; + +/** The id of an object part's value nothing holds: no entry has it. */ +export const missing = 0xFFFFFFFF; + +/** What an object part's value is interned by: itself for a primitive, which a `Map` compares by SameValueZero, else its structure. */ +const internKey = (value: unknown): unknown => typeof value === "object" && value !== null ? `\u0000${canonical(value)}` : value; + +/** A value's structure as text, a plain object's keys sorted, so equal structures give equal text. */ +const canonical = (value: unknown): string => JSON.stringify(value, (_, inner: unknown) => { + if (typeof inner !== "object" || inner === null || Array.isArray(inner)) return inner; + const sorted: { [key: string]: unknown } = {}; + for (const key of Object.keys(inner).sort()) sorted[key] = Reflect.get(inner, key); + return sorted; +}); + +/** The id an object part's value is interned under, or `missing`. */ +const idOf = (interned: Interned, value: unknown): number => interned.ids.get(internKey(value)) ?? missing; + +/** Interns a value, as one more entry holds it, and returns its id. */ +const internValue = (interned: Interned, value: unknown): number => { + const key = internKey(value); + const found = interned.ids.get(key); + if (found !== undefined) { + interned.counts[found]++; + return found; + } + const id = interned.free.pop() ?? interned.values.length; + interned.ids.set(key, id); + interned.values[id] = value; + interned.counts[id] = 1; + return id; +}; + +/** One fewer entry holds the value interned under `id`; with none, it's forgotten. */ +export const release = (part: KeyPart, id: number): void => { + if (part.kind !== "object" || id === missing) return; + const { interned } = part; + if (--interned.counts[id] > 0) return; + interned.ids.delete(internKey(interned.values[id])); + interned.values[id] = undefined; + interned.free.push(id); +}; + +/** Interns an id once more, for an entry copied with its key. */ +export const retain = (part: KeyPart, id: number): void => { + if (part.kind === "object" && id !== missing) part.interned.counts[id]++; +}; + +/** A multiply-xorshift hash of `count` words from `at`, the same on every thread. */ +export const hashWords = (words: Uint32Array, at: number, count: number): number => { + let hash = 0x811C9DC5; + for (let k = 0; k < count; k++) { + hash = Math.imul(hash ^ words[at + k], 0x9E3779B1); + hash ^= hash >>> 15; + } + hash = Math.imul(hash ^ (hash >>> 16), 0x85EBCA6B); + hash = Math.imul(hash ^ (hash >>> 13), 0xC2B2AE35); + return (hash ^ (hash >>> 16)) >>> 0; +}; + +/** Code points of two strings compared, as `@adobe/data` orders strings, never by locale. */ +export const compareStrings = (a: string, b: string): number => { + const length = Math.min(a.length, b.length); + for (let k = 0; k < length; k++) { + const x = a.charCodeAt(k); + const y = b.charCodeAt(k); + if (x === y) continue; + // A surrogate encodes a code point above every unit that isn't one. + const xs = x >= 0xD800 && x <= 0xDFFF; + const ys = y >= 0xD800 && y <= 0xDFFF; + if (xs !== ys) return xs ? 1 : -1; + return x < y ? -1 : 1; + } + return a.length - b.length; +}; + +/** Two values of an object part in order: strings by code point, anything else as equal. */ +export const compareObjects = (a: unknown, b: unknown): number => + typeof a === "string" && typeof b === "string" ? compareStrings(a, b) : 0; + +/** + * Whether a world keeps an index as a sorted table: a sorted one, and one without a key, whose entities a sorted table + * keeps in entity order, an entity entering or leaving moving at most a block's entries. + */ +export const keptSorted = (declaration: { readonly key: readonly unknown[]; readonly sorted?: boolean }): boolean => declaration.sorted === true || declaration.key.length === 0; diff --git a/packages/ecs/src/implementation/world/index-lookups-csp.test.ts b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts new file mode 100644 index 000000000..184d2a5f8 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; + +const spot = Symbol("indexLookupsCspTest/spot"); +const size = Symbol("indexLookupsCspTest/size"); +declare module "@adobe/ecs" { + interface Components { + [spot]: { type: readonly [number, number] }; + [size]: { type: number }; + } +} +Component.register(spot, { type: { type: "array", items: { type: "integer", minimum: -100, maximum: 100 }, minItems: 2, maxItems: 2 } }); +Component.register(size, { type: { type: "number" } }); + +const bySpot = Index.create({ key: [spot], components: { has: [size] } }); +const bySize = Index.create({ key: [size], sorted: true, unique: true }); + +describe("index lookups where code can't be generated", () => { + it("encode their keys in a loop, finding the same rows", async () => { + const generate = globalThis.Function; + // A content security policy without 'unsafe-eval' makes `new Function` throw. + globalThis.Function = new Proxy(generate, { construct: () => { throw new EvalError("blocked"); } }); + try { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create(({ commands }) => { + for (let k = 0; k < 6; k++) commands.spawn([spot, size])({ [spot]: [k % 2, 0], [size]: k }); + })); + let found: number[] = []; + await world.run(System.create({ lookup: { at: { index: bySpot, read: [size] }, sized: { index: bySize, read: [size] } } }, ({ lookup }) => { + const list = lookup.at([1, 0]); + const range = lookup.sized({ ">": 1, "<=": 4 }); + found = [...Array.from({ length: list.length }, (_, i) => list.at(i)[size]), -1, ...Array.from({ length: range.length }, (_, i) => range.at(i)[size]), -1, lookup.sized(5)?.[size] ?? -2]; + })); + expect(found).toEqual([1, 3, 5, -1, 2, 3, 4, -1, 5]); + } + finally { + globalThis.Function = generate; + } + }); +}); diff --git a/packages/ecs/src/implementation/world/index-lookups.ts b/packages/ecs/src/implementation/world/index-lookups.ts new file mode 100644 index 000000000..cb4666afa --- /dev/null +++ b/packages/ecs/src/implementation/world/index-lookups.ts @@ -0,0 +1,623 @@ +import type { Scalar } from '../../interface/registry/layout.js'; +import { encodeScalar, encodeValue, hashWords, orderF32, orderF64, partWords, type KeyPart } from './index-keys.js'; +import { entryWords, findBucket, lengthOf, recordKey, recordLength, recordStart, startOf, type HashView } from './index-hash.js'; +import { blockAt, blockBase, bound, firstPosition, treeMaximum, type SortedView } from './index-sorted.js'; +import { sharedRows, type Row, type RowArchetype, type RowHost } from './rows.js'; +import { idOf, type Key, type State } from './state.js'; +import { watchedBy } from '../../interface/registry/index.js'; + +/** A system's `lastRun` while it runs, which its lists' `changed` compares with: -1 in finalizers, which have none. */ +export type Since = { value: number }; + +/** + * How a lookup call passes its key, by argument: `0` a value whole, `n` a tuple literal flattened into `n` scalars, and + * for a comparison literal its operators, each with its operand passed in the same way. The plugin gives each call + * site in a main-thread body its shape, so a literal builds no array or object. + */ +export type Shape = readonly (number | readonly (readonly [string, number])[])[]; + +/** What a lookup reads of an index: on the world's thread the index, on a joined thread its views of it. */ +export type IndexView = { + readonly parts: readonly KeyPart[]; + readonly words: number; + readonly unique: boolean; + readonly hash: HashView | null; + readonly sorted: SortedView | null; +}; + +/** An archetype a lookup's rows bind to, by its id: the world's, or a joined thread's view of it. */ +export type LookupArchetype = RowArchetype & { readonly version: number }; + +/** A lookup through an index from a body, behind the function it calls, on the world's thread or a joined thread. */ +export type IndexLookup = { + readonly rows: RowHost; + readonly archetypeAt: (id: number) => LookupArchetype | undefined; + readonly index: IndexView; + readonly unique: boolean; + /** What rows read, and the components whose changes its lists report. */ + readonly reads: readonly Key[]; + readonly readIds: readonly number[]; + readonly watched: readonly number[]; + readonly since: Since; + /** + * How many times the world's archetypes' arrays were replaced, while which rows bound to them stay current: on the + * world's thread. Null on a joined thread, whose rows check their archetype's version each time. + */ + readonly replaced: { readonly count: number } | null; +}; + +/** The lookup behind each function bodies are given, for the plugin's call sites. */ +const lookups = new WeakMap(); + +/** The index lookup behind a function a body was given, or undefined for any other. */ +export const indexLookupOf = (lookup: unknown): IndexLookup | undefined => typeof lookup === "function" ? lookups.get(lookup) : undefined; + +/** Rows a list's `at` gives, each bound to the archetype of the entry it was last pointed at, made by `make`. */ +type Pool = { + /** By position: its row, or undefined for one not made yet. */ + readonly rows: (Row | undefined)[]; + readonly archetypes: number[]; + readonly versions: number[]; + readonly make: (archetype: RowArchetype) => Row; + /** The lookup's `replaced` count when every row was last checked against its archetype's version. */ + replaced: number; + /** The longest list a lookup made rows ahead for. */ + ahead: number; +}; + +/** Each lookup's maker of rows, so every row its sites give shares one hidden class. */ +const makers = new WeakMap Row>(); + +const poolOf = (lookup: IndexLookup): Pool => { + let make = makers.get(lookup); + if (make === undefined) makers.set(lookup, make = sharedRows(lookup.rows, lookup.reads, lookup.readIds)); + return { rows: [], archetypes: [], versions: [], make, replaced: lookup.replaced?.count ?? 0, ahead: 0 }; +}; + +/** Binds row `n` of a pool to an archetype, making it first if the pool has none there yet. */ +const bindRow = (pool: Pool, n: number, archetypeId: number, archetype: LookupArchetype): Row => { + const { rows } = pool; + // Positions before `n` are filled first, so the arrays stay dense. + while (rows.length < n) { + rows.push(undefined); + pool.archetypes.push(-1); + pool.versions.push(-1); + } + let found = rows[n]; + if (found === undefined) found = rows[n] = pool.make(archetype); + else found.bind(archetype); + pool.archetypes[n] = archetypeId; + pool.versions[n] = archetype.version; + return found; +}; + +/** Rows a lookup makes ahead of `at`, at most: a long list's later rows are made as `at` reaches them. */ +const madeAhead = 256; + +/** + * Makes a pool's rows up to `length`, at most `madeAhead`, bound to an archetype entries likely have, when a site's + * lookup found a list longer than any before. Making them here, not in `at`, keeps the one-time work out of the path a + * body inlines. + */ +const growPool = (lookup: IndexLookup, pool: Pool, length: number, archetypeId: number): void => { + pool.ahead = length; + const archetype = lookup.archetypeAt(archetypeId); + if (archetype === undefined) return; + const end = Math.min(length, madeAhead); + for (let n = pool.rows.length; n < end; n++) bindRow(pool, n, archetypeId, archetype); +}; + +/** Binds again the rows whose archetype's arrays were replaced since the pool last checked, on the world's thread. */ +const refreshPool = (lookup: IndexLookup, pool: Pool, replaced: { readonly count: number }): void => { + for (let n = 0; n < pool.rows.length; n++) { + const archetype = pool.rows[n] === undefined ? undefined : lookup.archetypeAt(pool.archetypes[n]); + if (archetype !== undefined && archetype.version !== pool.versions[n]) bindRow(pool, n, pool.archetypes[n], archetype); + } + pool.replaced = replaced.count; +}; + +/** Binds row `n` again to an entry's archetype, or makes it, when its archetype or that's arrays changed. */ +const reboundAt = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number, row: number): object | undefined => { + const { replaced } = lookup; + if (replaced !== null && pool.replaced !== replaced.count) refreshPool(lookup, pool, replaced); + const archetype = lookup.archetypeAt(archetypeId); + if (archetype === undefined) return undefined; + const current = pool.rows[n]; + const found = current !== undefined && pool.archetypes[n] === archetypeId && pool.versions[n] === archetype.version ? current : bindRow(pool, n, archetypeId, archetype); + found.index = row; + return found.value; +}; + +/** Whether row `n` of a pool is bound to its archetype's current arrays, on a joined thread. */ +const currentVersion = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number): boolean => + pool.versions[n] === lookup.archetypeAt(archetypeId)?.version; + +/** + * Points row `n` of a pool at an entry's archetype and row, binding it again only when its archetype or that's arrays + * changed: on the world's thread, when any archetype's were replaced since the pool last checked. + */ +const rowAt = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number, row: number): object | undefined => { + const found = pool.rows[n]; + const { replaced } = lookup; + if (found === undefined || pool.archetypes[n] !== archetypeId || (replaced === null ? !currentVersion(lookup, pool, n, archetypeId) : pool.replaced !== replaced.count)) return reboundAt(lookup, pool, n, archetypeId, row); + found.index = row; + return found.value; +}; + +/** + * A list of rows a lookup found: a hash index's bucket. Valid until its site looks up again, and each row until the + * list does. + */ +export type List = { readonly length: number; readonly changed: boolean; at(index: number): object | undefined }; + +/** A list's state, hidden on it, which the functions every list of its kind shares read through `this`. */ +const listState = Symbol("list"); + +/** + * Gives a list its accessors and its state. Every list of a kind has the same functions, defined in the same order, so + * lists share one hidden class and a body's loads of `length` and `at` stay monomorphic whichever site made the list. + */ +const shareList = (state: S, length: (this: { readonly [listState]: S }) => number, changed: (this: { readonly [listState]: S }) => boolean, at: (this: { readonly [listState]: S }, index: number) => object | undefined): List => { + const list = {}; + Object.defineProperty(list, listState, { value: state }); + Object.defineProperty(list, "length", { get: length, enumerable: true, configurable: true }); + Object.defineProperty(list, "changed", { get: changed, enumerable: true, configurable: true }); + Object.defineProperty(list, "at", { value: at, enumerable: true, configurable: true, writable: true }); + // Its properties are the ones `List` names, just defined. + return list as List; +}; + +/** What a hash index's list points at, a bucket or -1 for a key no bucket holds, and what it reads. */ +type Found = { bucket: number; readonly lookup: IndexLookup; readonly hash: HashView; readonly pool: Pool }; +type HashList = { readonly [listState]: Found }; + +const hashLength = function (this: HashList): number { + const { bucket, hash } = this[listState]; + return bucket < 0 ? 0 : hash.records[bucket * hash.stride + recordLength]; +}; + +const hashChanged = function (this: HashList): boolean { + const { bucket, hash, lookup } = this[listState]; + const since = lookup.since.value; + if (bucket < 0) return hash.cleared > since; + if (hash.ticks[bucket] > since) return true; + const { watched } = lookup; + for (let k = 0; k < watched.length; k++) if ((hash.watched.get(watched[k])?.[bucket] ?? Infinity) > since) return true; + return false; +}; + +const hashAt = function (this: HashList, position: number): object | undefined { + const found = this[listState]; + const { bucket, hash } = found; + const record = bucket * hash.stride; + if (bucket < 0 || !(position >= 0 && position < hash.records[record + recordLength])) return undefined; + const at = (hash.records[record + recordStart] + position) * entryWords; + return rowAt(found.lookup, found.pool, position, hash.entries[at + 1], hash.entries[at + 2]); +}; + +const createFound = (lookup: IndexLookup, hash: HashView): Found => ({ bucket: -1, lookup, hash, pool: poolOf(lookup) }); + +const createList = (found: Found): List => shareList(found, hashLength, hashChanged, hashAt); + +/** Words of the key a site encodes its arguments into. */ +const keyOf = (lookup: IndexLookup): Uint32Array => new Uint32Array(Math.max(1, lookup.index.words)); + +/** Whether a part's leaves are a tuple's elements, each one scalar. */ +const simpleTuple = (part: KeyPart & { readonly kind: "leaves" }): boolean => { + for (let k = 0; k < part.leaves.length; k++) if (part.leaves[k].path.length !== 1) return false; + return true; +}; + +/** Encodes a key part from a site's arguments starting at `next`, as its shape passes it; returns the next argument. */ +const encodePart = (part: KeyPart, form: number, values: readonly unknown[], next: number, key: Uint32Array): number => { + if (form === 0 || part.kind !== "leaves" || part.leaves.length !== form || !simpleTuple(part)) { + // A literal the part's leaves don't take one scalar each from is rebuilt as the value it was. + encodeValue(part, form === 0 ? values[next] : values.slice(next, next + form), key, part.first); + return next + Math.max(1, form); + } + let word = part.first; + for (let k = 0; k < form; k++) word = encodeScalar(part.leaves[k].scalar, values[next + k], key, word); + return next + form; +}; + +/** + * One call site's lookup: its list, or for a unique index its row, reused each time it runs. `find` takes the values + * its shape passes, in order. + */ +export type Site = { readonly find: (...values: unknown[]) => unknown }; + +/** Where a site's argument goes: a scalar leaf's word, or a whole key part. */ +type Destination = { readonly part: number; readonly scalar: Scalar | null; readonly word: number }; + +/** Where each argument of a shape goes, or null for a shape the index's parts don't take. */ +const destinationsOf = (parts: readonly KeyPart[], shape: Shape): readonly Destination[] | null => { + if (shape.length !== parts.length) return null; + const destinations: Destination[] = []; + for (let p = 0; p < parts.length; p++) { + const form = shape[p]; + const part = parts[p]; + if (typeof form !== "number") return null; + if (form === 0) { + const leaf = part.kind === "leaves" && part.leaves.length === 1 && part.leaves[0].path.length === 0 ? part.leaves[0] : null; + destinations.push({ part: p, scalar: leaf?.scalar ?? null, word: part.first }); + continue; + } + if (part.kind !== "leaves" || part.leaves.length !== form || part.leaves.some(leaf => leaf.path.length !== 1)) return null; + let word = part.first; + for (const leaf of part.leaves) { + destinations.push({ part: p, scalar: leaf.scalar, word }); + word += leaf.scalar === "f64" ? 2 : 1; + } + } + return destinations; +}; + +/** + * Statements encoding argument `a${k}` into the key's words where it goes, each word into a constant `w${word}`: a + * scalar of one word directly, the rest through the key's array. + */ +const encodeStatement = (parts: readonly KeyPart[]) => ({ part, scalar, word }: Destination, k: number): string => { + switch (scalar) { + case "i32": return `const w${word} = ((a${k} | 0) ^ 0x80000000) >>> 0;`; + case "u32": return `const w${word} = a${k} >>> 0;`; + case "f32": return `f32[0] = a${k}; const w${word} = orderF32(u32[0]);`; + case "f64": return `f64[0] = a${k}; orderF64(u32[1], u32[0], key, ${word}); const w${word} = key[${word}], w${word + 1} = key[${word + 1}];`; + case "boolean": return `u8[0] = a${k}; const w${word} = u8[0] === 0 ? 0 : 1;`; + default: { + const words = Array.from({ length: partWords(parts[part]) }, (_, w) => `w${word + w} = key[${word + w}]`); + return `encodeValue(parts[${part}], a${k}, key, ${word});${words.length === 0 ? "" : ` const ${words.join(", ")};`}`; + } + } +}; + +/** Whether code can be generated here; a content security policy without `'unsafe-eval'` forbids it. */ +let generating = true; + +const scratch = new ArrayBuffer(8); +const f32 = new Float32Array(scratch); +const f64 = new Float64Array(scratch); +const u32 = new Uint32Array(scratch); +const u8 = new Uint8Array(scratch); + +/** + * A site's lookup of a hash index generated for its shape: one straight-line encoding of each argument into the key's + * words, the hash and the probe inline. Null where code can't be generated, or for a shape it doesn't take. + */ +const generatedFind = (lookup: IndexLookup, hash: HashView, shape: Shape, list: List, found: Found): ((...values: unknown[]) => unknown) | null => { + const { index } = lookup; + const destinations = destinationsOf(index.parts, shape); + if (!generating || destinations === null) return null; + const { words } = index; + const parameters = destinations.map((_, k) => `a${k}`); + const same = Array.from({ length: words }, (_, w) => ` && records[base + ${recordKey + w}] === w${w}`).join(""); + const body = [ + `return (${parameters.join(", ")}) => {`, + ...destinations.map(encodeStatement(index.parts)), + "let h = 0x811C9DC5;", + ...Array.from({ length: words }, (_, w) => `h = Math.imul(h ^ w${w}, 0x9E3779B1); h ^= h >>> 15;`), + "h = Math.imul(h ^ (h >>> 16), 0x85EBCA6B); h = Math.imul(h ^ (h >>> 13), 0xC2B2AE35); h = (h ^ (h >>> 16)) >>> 0;", + "const slots = table.slots, records = table.records, mask = (slots.length >> 1) - 1;", + "let bucket = -1;", + "for (let slot = h & mask; ; slot = (slot + 1) & mask) {", + "const filled = slots[2 * slot + 1]; if (filled === 0) break;", + `if (slots[2 * slot] !== h) continue;`, + `const base = (filled - 1) * ${hash.stride};`, + `if (true${same}) { bucket = filled - 1; break; }`, + "}", + ...lookup.unique ? [ + `if (bucket < 0 || records[bucket * ${hash.stride} + ${recordLength}] === 0) return null;`, + `const at = records[bucket * ${hash.stride} + ${recordStart}] * ${entryWords};`, + "const archetypeId = table.entries[at + 1], archetype = archetypeAt(archetypeId), row = pool.rows[0];", + "if (row === undefined || archetype === undefined || pool.archetypes[0] !== archetypeId || pool.versions[0] !== archetype.version) return rowAt(lookup, pool, 0, archetypeId, table.entries[at + 2]);", + "row.index = table.entries[at + 2];", + "return row.value;", + ] : [ + "found.bucket = bucket;", + `if (bucket >= 0 && records[bucket * ${hash.stride} + ${recordLength}] > pool.ahead) growPool(lookup, pool, records[bucket * ${hash.stride} + ${recordLength}], table.entries[records[bucket * ${hash.stride} + ${recordStart}] * ${entryWords} + 1]);`, + "return list;", + ], + "};", + ].join("\n"); + try { + const factory = new Function("key", "table", "found", "list", "pool", "lookup", "rowAt", "growPool", "encodeValue", "parts", "orderF32", "orderF64", "f32", "f64", "u32", "u8", "archetypeAt", body); + // The factory returns the function its body above makes. + const made: (...values: unknown[]) => unknown = factory(new Uint32Array(Math.max(1, words)), hash, found, list, found.pool, lookup, rowAt, growPool, encodeValue, index.parts, orderF32, orderF64, f32, f64, u32, u8, lookup.archetypeAt); + return made; + } + catch { + generating = false; + return null; + } +}; + +/** A site's lookup by `shape`: each argument a key part's value, or its scalars flattened, of the index's parts in order. */ +export const createSite = (lookup: IndexLookup, shape: Shape): Site => { + const { index } = lookup; + if (index.sorted !== null) return createSortedSite(lookup, index.sorted, shape); + if (index.hash === null) return { find: () => null }; + return createHashSite(lookup, index.hash, shape); +}; + +/** A site's lookup of a hash index: by the whole key. */ +const createHashSite = (lookup: IndexLookup, hash: HashView, shape: Shape): Site => { + const { index, unique } = lookup; + const key = keyOf(lookup); + const found = createFound(lookup, hash); + const list = createList(found); + const { pool } = found; + const generated = generatedFind(lookup, hash, shape, list, found); + if (generated !== null) return { find: generated }; + const { parts, words } = index; + const forms = shape.map(form => typeof form === "number" ? form : 0); + return { + find: (...values) => { + let next = 0; + for (let p = 0; p < parts.length; p++) next = encodePart(parts[p], forms[p] ?? 0, values, next, key); + const bucket = found.bucket = findBucket(hash, key, 0, hashWords(key, 0, words)); + if (!unique) { + if (bucket >= 0 && lengthOf(hash, bucket) > pool.ahead) growPool(lookup, pool, lengthOf(hash, bucket), hash.entries[startOf(hash, bucket) * entryWords + 1]); + return list; + } + if (bucket < 0 || lengthOf(hash, bucket) === 0) return null; + const at = startOf(hash, bucket) * entryWords; + return rowAt(lookup, pool, 0, hash.entries[at + 1], hash.entries[at + 2]); + }, + }; +}; + +/** A sorted list's rows: positions `low` to `high`, and for a comparison with `"!="` a second run, `second` to `end`. */ +type Range = { low: number; high: number; second: number; end: number }; + +/** A sorted list's place as `at` walks it: the position in `order` of a block, the position of its first entry, and its length. */ +type Cursor = { k: number; first: number; length: number }; + +/** + * What a sorted index's list holds: its range, its cursor, and what it reads; the rows of its last `madeAhead` + * positions past its first ones in `tail`, by their distance from its end, so a list read from its end, highest + * first, has its rows made ahead too. + */ +type Sorted = { readonly lookup: IndexLookup; readonly sorted: SortedView; readonly range: Range; readonly cursor: Cursor; readonly pool: Pool; readonly tail: Pool }; +type SortedList = { readonly [listState]: Sorted }; + +/** Whether a tree's ticks over the blocks around positions `low` to `high` are newer than `since`. */ +const newer = (sorted: SortedView, tree: Float64Array, low: number, high: number, since: number): boolean => { + // The whole index: the tree's root. + if (low <= 0 && high >= sorted.size) return tree[1] > since; + const last = sorted.size - 1; + return treeMaximum(sorted, tree, blockAt(sorted, Math.min(last, Math.max(low - 1, 0))), blockAt(sorted, Math.min(last, high))) > since; +}; + +const runChanged = (lookup: IndexLookup, sorted: SortedView, low: number, high: number, since: number): boolean => { + if (newer(sorted, sorted.tree, low, high, since)) return true; + const { watched } = lookup; + for (let k = 0; k < watched.length; k++) { + const watch = sorted.watched.get(watched[k]); + if (watch === undefined || newer(sorted, watch.tree, low, high, since)) return true; + } + return false; +}; + +const sortedLength = function (this: SortedList): number { + const { range } = this[listState]; + return range.high - range.low + range.end - range.second; +}; + +const sortedChanged = function (this: SortedList): boolean { + const { lookup, sorted, range } = this[listState]; + const since = lookup.since.value; + if (sorted.blocks === 0) return sorted.cleared > since; + return runChanged(lookup, sorted, range.low, range.high, since) || (range.end > range.second && runChanged(lookup, sorted, range.second, range.end, since)); +}; + +const sortedAt = function (this: SortedList, index: number): object | undefined { + const state = this[listState]; + const { sorted, range, cursor } = state; + const first = range.high - range.low; + const length = first + range.end - range.second; + if (!(index >= 0 && index < length)) return undefined; + const position = index < first ? range.low + index : range.second + index - first; + if (!(position >= cursor.first && position < cursor.first + cursor.length)) { + cursor.k = blockAt(sorted, position); + cursor.first = firstPosition(sorted, cursor.k); + cursor.length = sorted.lengths[sorted.order[cursor.k]]; + } + const base = blockBase(sorted, sorted.order[cursor.k]) + (position - cursor.first) * sorted.stride; + const archetypeId = sorted.entries[base + sorted.words + 1]; + const row = sorted.entries[base + sorted.words + 2]; + if (index < madeAhead || length - index > madeAhead) return rowAt(state.lookup, state.pool, index, archetypeId, row); + return rowAt(state.lookup, state.tail, length - 1 - index, archetypeId, row); +}; + +/** A list of a sorted index's rows, in key order, then entity order. */ +const createSortedList = (lookup: IndexLookup, sorted: SortedView, range: Range, cursor: Cursor, pool: Pool, tail: Pool): List => + shareList({ lookup, sorted, range, cursor, pool, tail }, sortedLength, sortedChanged, sortedAt); + +/** Whether a value a lookup was given for a sorted part is a comparison: a sorted part's own values are never plain objects. */ +const isComparison = (value: unknown): value is { readonly [operator: string]: unknown } => typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * A site's lookup of a sorted index: a prefix of the key, whose last part may be a comparison, giving the rows in key + * order; for a unique index, a whole key of plain values gives the row or null. + */ +const createSortedSite = (lookup: IndexLookup, sorted: SortedView, shape: Shape): Site => { + const { index, unique } = lookup; + const { parts } = index; + const probe = keyOf(lookup); + const objects: unknown[] = new Array(parts.length).fill(undefined); + const range: Range = { low: 0, high: 0, second: 0, end: 0 }; + const cursor: Cursor = { k: 0, first: 0, length: 0 }; + const pool = poolOf(lookup); + const tail = poolOf(lookup); + const list = createSortedList(lookup, sorted, range, cursor, pool, tail); + /** The list, its pools grown first when it's longer than any before, bound to the archetype of its last entry. */ + const listed = (): List => { + const length = range.high - range.low + range.end - range.second; + if (length > pool.ahead && sorted.blocks > 0) { + const last = sorted.order[sorted.blocks - 1]; + const archetypeId = sorted.entries[blockBase(sorted, last) + (sorted.lengths[last] - 1) * sorted.stride + sorted.words + 1]; + growPool(lookup, pool, length, archetypeId); + if (length > madeAhead) growPool(lookup, tail, length - madeAhead, archetypeId); + } + return list; + }; + const found = { k: 0, offset: 0 }; + /** + * The first position whose key's first `count` words are after the probe's, or not before them. A lower bound, the + * first row `at` gives, leaves the cursor at its block. + */ + const at = (count: number, after: boolean): number => { + if (count === 0) return after ? sorted.size : 0; + bound(sorted, probe, objects, count, after, found); + if (found.k >= sorted.blocks) return sorted.size; + const first = firstPosition(sorted, found.k); + if (!after) { + cursor.k = found.k; + cursor.first = first; + cursor.length = sorted.lengths[sorted.order[found.k]]; + } + return first + found.offset; + }; + /** Encodes part `p`'s value, the site's values from `next` as `form` passes it, into the probe; returns the next value. */ + const encode = (p: number, form: number, values: readonly unknown[], next: number): number => { + const part = parts[p]; + if (part.kind === "object") objects[p] = form === 0 ? values[next] : values.slice(next, next + form); + else if (form === 0) encodeValue(part, values[next], probe, part.first); + else if (part.kind === "leaves" && part.leaves.length === form && simpleTuple(part)) { + let word = part.first; + for (let k = 0; k < form; k++) word = encodeScalar(part.leaves[k].scalar, values[next + k], probe, word); + } + else encodeValue(part, values.slice(next, next + form), probe, part.first); + return next + Math.max(1, form); + }; + /** The site's values, copied from its arguments, reused. */ + const values: unknown[] = []; + let low = 0; + let high = 0; + let unequalLow = -1; + let unequalHigh = -1; + /** Narrows the range by an operator of the last part given, its operand the site's values from `next` as `operand` passes it. */ + const compare = (last: number, words: number, operator: string, operand: number, from: readonly unknown[], next: number): void => { + encode(last, operand, from, next); + switch (operator) { + case ">=": low = Math.max(low, at(words, false)); break; + case ">": low = Math.max(low, at(words, true)); break; + case "<": high = Math.min(high, at(words, false)); break; + case "<=": high = Math.min(high, at(words, true)); break; + case "==": low = Math.max(low, at(words, false)); high = Math.min(high, at(words, true)); break; + case "!=": unequalLow = at(words, false); unequalHigh = at(words, true); break; + default: break; + } + }; + const held: unknown[] = [undefined]; + const find = (): unknown => { + cursor.length = 0; + cursor.first = 0; + range.second = range.end = 0; + const given = Math.min(shape.length, parts.length); + let next = 0; + for (let p = 0; p < given - 1; p++) { + const form = shape[p]; + next = encode(p, typeof form === "number" ? form : 0, values, next); + } + if (given === 0) { + range.low = 0; + range.high = sorted.size; + return listed(); + } + const last = given - 1; + const lastForm = shape[last]; + const prefix = parts[last].first; + const words = prefix + partWords(parts[last]); + const whole = typeof lastForm === "number" ? (lastForm === 0 ? values[next] : undefined) : undefined; + if (typeof lastForm === "number" && !(lastForm === 0 && isComparison(whole))) { + encode(last, lastForm, values, next); + range.low = at(words, false); + range.high = at(words, true); + if (!unique || given < parts.length) return listed(); + if (range.high === range.low) return null; + bound(sorted, probe, objects, words, false, found); + const base = blockBase(sorted, sorted.order[found.k]) + found.offset * sorted.stride; + return rowAt(lookup, pool, 0, sorted.entries[base + sorted.words + 1], sorted.entries[base + sorted.words + 2]); + } + // A comparison on the last part given, within the prefix before it. + low = at(prefix, false); + high = prefix === 0 ? sorted.size : at(prefix, true); + unequalLow = -1; + unequalHigh = -1; + if (typeof lastForm === "number") { + const comparison = isComparison(whole) ? whole : {}; + for (const operator in comparison) { + held[0] = comparison[operator]; + compare(last, words, operator, 0, held, 0); + } + } + else { + for (let k = 0; k < lastForm.length; k++) { + const [operator, operand] = lastForm[k]; + compare(last, words, operator, operand, values, next); + next += Math.max(1, operand); + } + } + if (high < low) high = low; + range.low = low; + range.high = high; + if (unequalLow >= 0) { + range.high = Math.min(high, Math.max(low, unequalLow)); + range.second = Math.max(low, Math.min(high, unequalHigh)); + range.end = high; + if (range.second >= range.end) range.second = range.end = 0; + } + return listed(); + }; + // Without a key, or with none of it given: every row. + if (shape.length === 0) { + return { + find: (): unknown => { + cursor.length = 0; + cursor.first = 0; + range.low = 0; + range.high = sorted.size; + range.second = range.end = 0; + return listed(); + }, + }; + } + return { + find: (a0?: unknown, a1?: unknown, a2?: unknown, a3?: unknown, a4?: unknown, a5?: unknown, a6?: unknown, a7?: unknown, + a8?: unknown, a9?: unknown, a10?: unknown, a11?: unknown, a12?: unknown, a13?: unknown, a14?: unknown, a15?: unknown): unknown => { + values[0] = a0; values[1] = a1; values[2] = a2; values[3] = a3; values[4] = a4; values[5] = a5; values[6] = a6; values[7] = a7; + values[8] = a8; values[9] = a9; values[10] = a10; values[11] = a11; values[12] = a12; values[13] = a13; values[14] = a14; values[15] = a15; + return find(); + }, + }; +}; + +/** The shape of a call that passes each key part whole. */ +const wholeShapes: Shape[] = []; +const wholeShape = (count: number): Shape => wholeShapes[count] ??= new Array(count).fill(0); + +/** + * The function a body calls for a lookup through an index: called with a key, as code the plugin didn't rewrite calls + * it, it makes a new list each time; the plugin's call sites reuse their own (`prepared-lookup.ts`). + */ +export const lookupFunction = (lookup: IndexLookup): (...key: unknown[]) => unknown => { + const call = (...key: unknown[]): unknown => createSite(lookup, wholeShape(key.length)).find(...key); + lookups.set(call, lookup); + return call; +}; + +/** A lookup through one of the world's indexes, for a main-thread body or finalizer. */ +export const createIndexLookup = (state: State, index: IndexView, declared: { readonly read?: readonly Key[]; readonly changed?: readonly Key[] | true }, since: Since): (...key: unknown[]) => unknown => { + const reads = declared.read ?? []; + return lookupFunction({ + rows: state, + archetypeAt: id => state.archetypes[id], + index, + unique: index.unique, + reads, + readIds: reads.map(key => idOf(state, key)), + watched: watchedBy(declared).map(key => idOf(state, key)), + since, + replaced: state.replaced, + }); +}; diff --git a/packages/ecs/src/implementation/world/index-order.test.ts b/packages/ecs/src/implementation/world/index-order.test.ts new file mode 100644 index 000000000..6768e91b5 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-order.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import { backToBack } from "./loop.js"; + +const rank = Symbol("indexOrderTest/rank"); +const lane = Symbol("indexOrderTest/lane"); +const weight = Symbol("indexOrderTest/weight"); +const extra = Symbol("indexOrderTest/extra"); +const marker = Symbol("indexOrderTest/marker"); + +declare module "@adobe/ecs" { + interface Components { + [rank]: { type: number }; + [lane]: { type: number }; + [weight]: { type: number }; + [extra]: { type: true }; + [marker]: { type: number }; + } +} +Component.register(rank, { type: { type: "number" } }); +Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 100 } }); +Component.register(weight, { type: { type: "number" } }); +Component.register(extra, { type: { const: true } }); +Component.register(marker, { type: { type: "number" } }); + +/** By lane, then rank, of the entities that have a weight. */ +const ranked = Index.create({ name: "indexOrderTest/ranked", key: [lane, rank], sorted: true, components: { has: [weight] } }); + +/** Each row's lane and rank as one number, in the order rows ran. */ +let order: number[] = []; +const visit = System.create({ name: "indexOrderTest/visit", components: { read: [lane, rank] }, order: ranked }, row => { + order.push(row[lane] * 1000 + row[rank]); +}); +const visitChanged = System.create({ name: "indexOrderTest/visitChanged", components: { read: [lane, rank], changed: [weight] }, order: ranked }, row => { + order.push(row[lane] * 1000 + row[rank]); +}); +/** A kernel spawning a marker for each row in turn, so the markers' entities, in order, are the rows' order. */ +const markEach = System.create({ name: "indexOrderTest/markEach", kernel: "cpu", components: { read: [lane, rank] }, order: ranked }, (row, { commands }) => { + commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); +}); +const markChanged = System.create({ name: "indexOrderTest/markChanged", kernel: "cpu", components: { read: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { + commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); +}); +const keepTicking = System.create({ name: "indexOrderTest/keepTicking" }, () => {}); + +/** Changes the weight of every row whose weight ends in 3, a tenth of them. */ +const nudge = System.create({ name: "indexOrderTest/nudge", components: { write: [weight] } }, row => { + if (row[weight] % 10 === 3) row[weight] = row[weight] + 1; +}); + +/** Entities in two archetypes, interleaved in the index's order, some without the weight the index requires. */ +const seed = System.create({ name: "indexOrderTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = 0; k < input.count; k++) { + const values = { [lane]: k % 3, [rank]: (k * 37) % 101, [weight]: k }; + if (k % 5 === 4) commands.spawn([lane, rank])({ [lane]: k % 3, [rank]: (k * 37) % 101 }); + else if (k % 2 === 0) commands.spawn([lane, rank, weight, extra])(values); + else commands.spawn([lane, rank, weight])(values); + } +}); + +/** The lane and rank of every entity holding a weight, sorted by them, then by entity: the index's order. */ +const expected = async (world: CreatedWorld, only?: (weighed: number) => boolean): Promise => { + const entities = await Observe.toPromise(Observe.select(world, { required: [lane, rank, weight] })); + const rows = await Promise.all(entities.map(async entity => ({ entity, values: await Observe.toPromise(Observe.entity(world, entity, { required: [lane, rank, weight] })) }))); + return rows + .flatMap(({ entity, values }) => values === null || (only !== undefined && !only(values[weight])) ? [] : [{ entity, key: values[lane] * 1000 + values[rank] }]) + .sort((a, b) => a.key - b.key || (a.entity >>> 0) - (b.entity >>> 0)) + .map(({ key }) => key); +}; + +const markerValues: number[] = []; +const readMarkers = System.create({ name: "indexOrderTest/readMarkers", observer: true, components: { read: [marker] } }, row => { markerValues.push(row[marker]); }); + +/** Markers' values in the order they were spawned: their archetype's rows', in storage order. */ +const markers = async (world: CreatedWorld): Promise => { + markerValues.length = 0; + await world.run(readMarkers); + return [...markerValues]; +}; + +describe("systems ordering their rows by a sorted index", () => { + it("run the rows it holds in key order, then entity order, across archetypes, skipping rows it doesn't hold", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seed, { count: 300 }); + order = []; + await world.run(visit); + expect(order).toEqual(await expected(world)); + expect(order.length).toBe(240); + }); + it("with change filters, run the rows that pass, in the index's order, whether few or most passed", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seed, { count: 3000 }); + await world.run(visitChanged); + await world.run(nudge); + order = []; + await world.run(visitChanged); + expect(order).toEqual(await expected(world, weighed => weighed % 10 === 4)); + expect(order.length).toBeGreaterThan(100); + // Most rows change: the index is scanned rather than the rows' positions sorted. + await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = row[weight] + 2; })); + order = []; + await world.run(visitChanged); + expect(order).toEqual(await expected(world)); + }); + it.each([[true], [false]])("give kernels' rows, commands included, in the index's order, WASM %s", async wasm => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(seed, { count: 300 }); + await world.run(markEach); + expect(await markers(world)).toEqual(await expected(world)); + }); + it.each([[true], [false]])("give kernels' rows that pass their change filters in the index's order, WASM %s", async wasm => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(seed, { count: 3000 }); + await world.run(markChanged); + const before = (await markers(world)).length; + await world.run(nudge); + await world.run(markChanged); + expect((await markers(world)).slice(before)).toEqual(await expected(world, weighed => weighed % 10 === 4)); + }); + it("hold their index while registered and release it with them", async () => { + const driver = backToBack(); + await using world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + await world.run(seed, { count: 10 }); + /** Once `count` more ticks ran. */ + const ticks = async (count: number): Promise => { + const first = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await new Promise(resolve => { + const stop = Observe.resource(world, clock)(({ tick }) => { + if (tick < first + count) return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + }; + // Ticks run only while a system is added. + world.add(keepTicking); + await ticks(1); + const before = (await world.tables()).indexes; + world.add(visit); + await ticks(2); + expect((await world.tables()).indexes).toBe(before + 1); + world.remove(visit); + await ticks(2); + expect((await world.tables()).indexes).toBe(before); + driver.close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-order.ts b/packages/ecs/src/implementation/world/index-order.ts new file mode 100644 index 000000000..b9876726b --- /dev/null +++ b/packages/ecs/src/implementation/world/index-order.ts @@ -0,0 +1,182 @@ +import type { Archetype } from './archetype.js'; +import { allocateArray, freeArray } from './arrays.js'; +import { blockAt, blockBase, firstPosition, type SortedTable } from './index-sorted.js'; +import type { Memory } from './memory.js'; +import { forEachPassingRange, type Filter } from './ranges.js'; +import type { Run } from './up.js'; + +// A system's rows in a sorted index's order (`order`): runs of consecutive entries of one matched archetype, in key +// order and then entity order, each a list of rows, as `up`'s runs are. + +/** Below this share of the index passing a run's change filters, passing rows are found by their ticks and sorted by position; above it the index is scanned. */ +const sparseShare = 1 / 8; + +type MutableRun = { owner: number; parent: number; rows: Int32Array; parents: Int32Array; start: number; end: number }; + +/** What a system's change filters pass of a matched archetype: its filters, and the tick rows pass after, -1 for every row. */ +export type Passing = { readonly filters: (archetype: Archetype) => readonly Filter[]; readonly since: (archetype: Archetype) => number }; + +export type Ordered = { + /** + * The runs of the rows of `matches` the index holds, in its order, those that pass with `passing`, or all without. + * Valid until the next call; their rows are in the arena, so WASM reads them. + */ + readonly runs: (sorted: SortedTable, matches: readonly Archetype[], passing: Passing | null) => readonly Run[]; + /** By archetype id, its position among `matches`, or -1: kept until the matches grow. */ + readonly owners: (matches: readonly Archetype[]) => Int32Array; + readonly release: () => void; +}; + +export const createOrdered = (memory: Memory): Ordered => { + let rows = new Int32Array(0); + /** By archetype id: its position in the matches, or -1. */ + let owners = new Int32Array(0); + let matched: readonly Archetype[] = []; + let ownersFor = 0; + /** Positions in the index of the rows that pass, for sorting. */ + let positions = new Int32Array(0); + /** Runs made so far, reused; and those of the last call. */ + const made: MutableRun[] = []; + const runs: MutableRun[] = []; + let count = 0; + let written = 0; + /** Per owner: its filters and since for this call. */ + const filters: (readonly Filter[])[] = []; + const sinces: number[] = []; + + const ownersOf = (matches: readonly Archetype[]): Int32Array => { + if (matched === matches && ownersFor === matches.length) return owners; + let length = 0; + for (const archetype of matches) length = Math.max(length, archetype.id + 1); + if (owners.length < length) owners = new Int32Array(Math.max(length, 2 * owners.length)); + owners.fill(-1); + matches.forEach((archetype, owner) => { owners[archetype.id] = owner; }); + matched = matches; + ownersFor = matches.length; + return owners; + }; + + const fitRows = (length: number): void => { + if (rows.length >= length) return; + if (rows.length > 0) freeArray(memory, rows); + rows = allocateArray(memory, Int32Array, Math.max(length, 2 * rows.length, 64)); + }; + + /** Appends a row of `owner`, starting a run when the previous row's owner differs. */ + const append = (owner: number, row: number): void => { + if (count === 0 || made[count - 1].owner !== owner) { + const run = made[count] ??= { owner, parent: -1, rows, parents: rows, start: 0, end: 0 }; + run.owner = owner; + run.rows = run.parents = rows; + run.start = run.end = written; + count++; + } + rows[written++] = row; + made[count - 1].end = written; + }; + + /** Whether a row of `owner` passes its archetype's filters. */ + const passes = (owner: number, row: number): boolean => { + const since = sinces[owner]; + if (since < 0) return true; + const found = filters[owner]; + if (found.length === 0) return true; + for (let k = 0; k < found.length; k++) if (found[k].ticks[row] > since) return true; + return false; + }; + + /** Every entry of the index, in order, whose archetype is matched and, when `filtered`, whose row passes. */ + const scan = (sorted: SortedTable, slots: Int32Array, filtered: boolean): void => { + const { order, lengths, entries, stride, words, blocks } = sorted; + for (let k = 0; k < blocks; k++) { + const block = order[k]; + const base = blockBase(sorted, block); + for (let j = 0, length = lengths[block]; j < length; j++) { + const at = base + j * stride + words; + const archetype = entries[at + 1]; + const owner = archetype < slots.length ? slots[archetype] : -1; + if (owner < 0) continue; + const row = entries[at + 2]; + if (filtered && !passes(owner, row)) continue; + append(owner, row); + } + } + }; + + /** The rows that pass, found by their ticks, sorted by their positions in the index; false when more pass than a scan costs. */ + const gather = (sorted: SortedTable, matches: readonly Archetype[], slots: Int32Array): boolean => { + const most = Math.max(64, sorted.size * sparseShare); + let passed = 0; + if (positions.length < most) positions = new Int32Array(Math.ceil(most)); + let fits = true; + const { blockOf, positions: placed, entries, stride, words, lengths } = sorted; + matches.forEach((archetype, owner) => { + if (!fits || archetype.count === 0) return; + forEachPassingRange(filters[owner], sinces[owner], 0, archetype.count, (start, end) => { + for (let row = start; row < end && fits; row++) { + const entity = archetype.entities[row] >>> 0; + const block = entity < blockOf.length ? blockOf[entity] : -1; + if (block < 0) continue; + if (passed >= most) { + fits = false; + return; + } + // Its offset in its block, by its entity index. + const base = blockBase(sorted, block); + let offset = 0; + while (offset < lengths[block] && entries[base + offset * stride + words] !== entity) offset++; + positions[passed++] = firstPosition(sorted, placed[block]) + offset; + } + }); + }); + if (!fits) return false; + const sortedPositions = positions.subarray(0, passed).sort(); + let k = -1; + let first = 0; + let length = 0; + let base = 0; + for (let n = 0; n < passed; n++) { + const position = sortedPositions[n]; + if (k < 0 || position >= first + length) { + k = blockAt(sorted, position); + first = firstPosition(sorted, k); + length = lengths[sorted.order[k]]; + base = blockBase(sorted, sorted.order[k]); + } + const at = base + (position - first) * stride + words; + const owner = slots[entries[at + 1]]; + if (owner >= 0) append(owner, entries[at + 2]); + } + return true; + }; + + return { + runs: (sorted, matches, passing) => { + count = 0; + written = 0; + const slots = ownersOf(matches); + fitRows(sorted.size); + let filtered = false; + if (passing !== null) { + for (let owner = 0; owner < matches.length; owner++) { + filters[owner] = passing.filters(matches[owner]); + sinces[owner] = passing.since(matches[owner]); + if (sinces[owner] >= 0 && filters[owner].length > 0) filtered = true; + } + } + if (!filtered || !gather(sorted, matches, slots)) { + count = 0; + written = 0; + scan(sorted, slots, filtered); + } + for (let k = 0; k < count; k++) runs[k] = made[k]; + runs.length = count; + return runs; + }, + owners: ownersOf, + release: () => { + if (rows.length > 0) freeArray(memory, rows); + rows = new Int32Array(0); + }, + }; +}; diff --git a/packages/ecs/src/implementation/world/index-readers.ts b/packages/ecs/src/implementation/world/index-readers.ts new file mode 100644 index 000000000..d61a12167 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-readers.ts @@ -0,0 +1,324 @@ +import type { TypedArray, View } from '../../interface/registry/layout.js'; +import { encodeScalar } from './index-keys.js'; +import type { WasmKeyValue, WasmProbe } from './wasm.js'; + +/** Entries a sorted index's block holds at most (`index-sorted.ts`). */ +const blockEntries = 128; + +/** + * Where a lookup through an index finds its index's arrays: each an array and the element its first is at, so the + * world's thread reads the index's own arrays and other threads a view of the whole memory; and its counts and ticks. + * The fields follow the parameters WASM kernels take (`wasm.ts`, `indexParameterCount`). + */ +export type IndexSource = { + kind: "hash" | "sorted"; + /** A hash index's slots, two words each, and their mask; its buckets' records; its entries; its buckets' ticks. */ + slots: Uint32Array; slotsBase: number; mask: number; + records: Uint32Array; recordsBase: number; + hashEntries: Int32Array; hashEntriesBase: number; + ticks: Float64Array; ticksBase: number; + /** A sorted index's fences, blocks in order, lengths, entries, Fenwick tree, entry count, tick tree and its leaves. */ + fences: Uint32Array; fencesBase: number; blocks: number; + order: Int32Array; orderBase: number; + lengths: Int32Array; lengthsBase: number; + sortedEntries: Uint32Array; sortedEntriesBase: number; + counts: Int32Array; countsBase: number; size: number; + tree: Float64Array; treeBase: number; leaves: number; + /** When a bucket or the last entry was dropped, the system's `lastRun`, and each watched component's ticks or tree. */ + cleared: number; + since: number; + watched: Float64Array[]; watchedBases: number[]; +}; + +const none = new Uint32Array(0); + +export const createIndexSource = (kind: "hash" | "sorted"): IndexSource => ({ + kind, + slots: none, slotsBase: 0, mask: 0, records: none, recordsBase: 0, hashEntries: new Int32Array(0), hashEntriesBase: 0, ticks: new Float64Array(0), ticksBase: 0, + fences: none, fencesBase: 0, blocks: 0, order: new Int32Array(0), orderBase: 0, lengths: new Int32Array(0), lengthsBase: 0, + sortedEntries: none, sortedEntriesBase: 0, counts: new Int32Array(0), countsBase: 0, size: 0, tree: new Float64Array(0), treeBase: 0, leaves: 0, + cleared: 0, since: -1, watched: [], watchedBases: [], +}); + +/** + * Points a source at an index through the whole memory's views, from its parameters as a WASM kernel takes them: + * addresses in bytes, counts and ticks. + */ +export const sourceFromParameters = (source: IndexSource, memory: { readonly u32: Uint32Array; readonly i32: Int32Array; readonly f64: Float64Array }, parameters: readonly number[], at: number, watched: number): void => { + const { u32, i32, f64 } = memory; + if (source.kind === "hash") { + source.slots = u32; source.slotsBase = parameters[at] >>> 2; source.mask = parameters[at + 1]; + source.records = u32; source.recordsBase = parameters[at + 2] >>> 2; + source.hashEntries = i32; source.hashEntriesBase = parameters[at + 3] >>> 2; + source.ticks = f64; source.ticksBase = parameters[at + 4] >>> 3; + source.cleared = parameters[at + 5]; source.since = parameters[at + 6]; + source.watched.length = source.watchedBases.length = 0; + for (let k = 0; k < watched; k++) { source.watched.push(f64); source.watchedBases.push(parameters[at + 7 + k] >>> 3); } + return; + } + source.fences = u32; source.fencesBase = parameters[at] >>> 2; source.blocks = parameters[at + 1]; + source.order = i32; source.orderBase = parameters[at + 2] >>> 2; + source.lengths = i32; source.lengthsBase = parameters[at + 3] >>> 2; + source.sortedEntries = u32; source.sortedEntriesBase = parameters[at + 4] >>> 2; + source.counts = i32; source.countsBase = parameters[at + 5] >>> 2; source.size = parameters[at + 6]; + source.tree = f64; source.treeBase = parameters[at + 7] >>> 3; source.leaves = parameters[at + 8]; + source.cleared = parameters[at + 9]; source.since = parameters[at + 10]; + source.watched.length = source.watchedBases.length = 0; + for (let k = 0; k < watched; k++) { source.watched.push(f64); source.watchedBases.push(parameters[at + 11 + k] >>> 3); } +}; + +/** A leaf bodies read of a lookup's rows: its component, the view it's stored through, its stride and offset in elements. */ +export type RowLeaf = { readonly component: number; readonly view: View; readonly unitStride: number; readonly unitOffset: number; readonly boolean: boolean }; + +/** What a JS kernel's lookup through an index needs besides its source: its key's words, entry stride, probes and row leaves. */ +export type IndexReaderSetup = { + readonly kind: "hash" | "sorted"; + readonly words: number; + readonly stride: number; + /** By probe of the kernel, its plan, or null for a probe of another lookup. */ + readonly probes: readonly (WasmProbe | null)[]; + readonly rowLeaves: readonly (RowLeaf | null)[]; +}; + +/** An archetype as a reader reads its rows: its version, its entities, and a column's view, by component and view. */ +export type ReaderArchetype = { readonly version: number; readonly entities: Float64Array; readonly column: (component: number, view: View) => TypedArray | undefined }; + +/** + * A handle a kernel's site holds, which its reader fills (`compile-kernel.ts`): a list's bucket, start and length, or + * runs and cursor; a row's archetype, row and whether it was found (`f`). + */ +type Handle = { a: number; b: number; c: number; d: number; e: number; f: number; g: number; h: number }; + +/** A lookup through an index as a JS kernel reads it: probes, lists' lengths, `changed` and rows, rows' leaves and entities. */ +export type IndexReader = { + readonly source: IndexSource; + readonly probe: (probe: number, list: Handle, first: Handle, a0?: number, a1?: number, a2?: number, a3?: number, a4?: number, a5?: number, a6?: number, a7?: number, + a8?: number, a9?: number, a10?: number, a11?: number, a12?: number, a13?: number, a14?: number, a15?: number) => void; + readonly length: (list: Handle) => number; + readonly changed: (list: Handle) => boolean; + readonly row: (row: Handle, list: Handle, index: number) => void; + readonly leaf: (row: Handle, leaf: number) => number | boolean; + readonly entity: (row: Handle) => number; +}; + +/** A reader over `source`, reading rows through `archetypeAt`. */ +export const createIndexReader = (setup: IndexReaderSetup, source: IndexSource, archetypeAt: (id: number) => ReaderArchetype | undefined): IndexReader => { + const { words, stride } = setup; + const key = new Uint32Array(Math.max(1, words)); + /** By archetype id: the version its views were found at, and each row leaf's view there. */ + const views: (TypedArray | undefined)[][] = []; + const versions: number[] = []; + const leafView = (archetype: number, leaf: number): TypedArray | undefined => { + const found = archetypeAt(archetype); + if (found === undefined) return undefined; + if (versions[archetype] !== found.version) { + versions[archetype] = found.version; + views[archetype] = setup.rowLeaves.map(row => row === null ? undefined : found.column(row.component, row.view)); + } + return views[archetype]?.[leaf]; + }; + const encode = (values: readonly number[], targets: readonly WasmKeyValue[], at: number): number => { + let next = at; + for (const target of targets) encodeScalar(target.scalar, values[next++], key, target.word); + return next; + }; + /** The first position whose key's first `count` words are after the key's, or not before them (`bound` in `index-sorted.ts`). */ + const bound = (count: number, after: boolean): number => { + if (count === 0) return after ? source.size : 0; + const { fences, fencesBase, order, orderBase, lengths, lengthsBase, sortedEntries, sortedEntriesBase } = source; + const passes = (array: Uint32Array, base: number): boolean => { + for (let k = 0; k < count; k++) { + const a = array[base + k]; + const b = key[k]; + if (a !== b) return a > b; + } + return !after; + }; + let low = 0; + let high = source.blocks; + while (low < high) { + const middle = (low + high) >>> 1; + if (passes(fences, fencesBase + middle * (words + 1))) high = middle; + else low = middle + 1; + } + if (low >= source.blocks) return source.size; + const k = low; + const block = order[orderBase + k]; + const base = sortedEntriesBase + block * blockEntries * stride; + let first = 0; + let last = lengths[lengthsBase + block]; + while (first < last) { + const middle = (first + last) >>> 1; + if (passes(sortedEntries, base + middle * stride)) last = middle; + else first = middle + 1; + } + return prefix(k) + first; + }; + const prefix = (k: number): number => { + const { counts, countsBase } = source; + let sum = 0; + for (let node = k; node > 0; node -= node & -node) sum += counts[countsBase + node]; + return sum; + }; + const blockAt = (position: number): number => { + const { counts, countsBase, blocks } = source; + let k = 0; + let remaining = position; + let step = 1; + while (step * 2 <= blocks) step *= 2; + for (; step > 0 && blocks > 0; step >>= 1) { + const next = k + step; + if (next <= blocks && counts[countsBase + next] <= remaining) { + k = next; + remaining -= counts[countsBase + next]; + } + } + return k; + }; + const treeNewer = (tree: Float64Array, base: number, low: number, high: number): boolean => { + // The whole index: the tree's root. + if (low <= 0 && high >= source.size) return tree[base + 1] > source.since; + const last = source.size - 1; + const from = blockAt(Math.min(last, Math.max(low - 1, 0))); + const to = blockAt(Math.min(last, high)); + let maximum = 0; + for (let node = from + source.leaves, limit = to + source.leaves + 1; node < limit; node >>= 1, limit >>= 1) { + if ((node & 1) === 1) maximum = Math.max(maximum, tree[base + node++]); + if ((limit & 1) === 1) maximum = Math.max(maximum, tree[base + --limit]); + } + return maximum > source.since; + }; + const runChanged = (low: number, high: number): boolean => { + if (treeNewer(source.tree, source.treeBase, low, high)) return true; + for (let k = 0; k < source.watched.length; k++) if (treeNewer(source.watched[k], source.watchedBases[k], low, high)) return true; + return false; + }; + const length = (list: Handle): number => source.kind === "hash" ? list.c : list.b - list.a + list.d - list.c; + const row = (target: Handle, list: Handle, index: number): void => { + target.f = 0; + if (!(index >= 0 && index < length(list))) return; + let position = Math.floor(index); + let archetype: number; + let found: number; + if (source.kind === "hash") { + const at = source.hashEntriesBase + (list.b + position) * 3; + archetype = source.hashEntries[at + 1]; + found = source.hashEntries[at + 2]; + } + else { + const first = list.b - list.a; + position = position < first ? list.a + position : list.c + position - first; + // The cursor: block position `e`, its first entry's position `g`, its length `h`. + if (!(position >= list.g && position < list.g + list.h)) { + list.e = blockAt(position); + list.g = prefix(list.e); + list.h = source.lengths[source.lengthsBase + source.order[source.orderBase + list.e]]; + } + const block = source.order[source.orderBase + list.e]; + const at = source.sortedEntriesBase + block * blockEntries * stride + (position - list.g) * stride; + archetype = source.sortedEntries[at + words + 1]; + found = source.sortedEntries[at + words + 2]; + } + target.a = archetype; + target.b = found; + target.f = 1; + }; + /** A probe's values, copied from its arguments, reused. */ + const values: number[] = []; + return { + source, + probe: (probe, list, first, a0 = NaN, a1 = NaN, a2 = NaN, a3 = NaN, a4 = NaN, a5 = NaN, a6 = NaN, a7 = NaN, a8 = NaN, a9 = NaN, a10 = NaN, a11 = NaN, a12 = NaN, a13 = NaN, a14 = NaN, a15 = NaN) => { + const plan = setup.probes[probe]; + if (plan === null || plan === undefined) return; + values[0] = a0; values[1] = a1; values[2] = a2; values[3] = a3; values[4] = a4; values[5] = a5; values[6] = a6; values[7] = a7; + values[8] = a8; values[9] = a9; values[10] = a10; values[11] = a11; values[12] = a12; values[13] = a13; values[14] = a14; values[15] = a15; + let next = encode(values, plan.values, 0); + if (source.kind === "hash") { + let hash = 0x811C9DC5; + for (let k = 0; k < words; k++) { + hash = Math.imul(hash ^ key[k], 0x9E3779B1); + hash ^= hash >>> 15; + } + hash = Math.imul(hash ^ (hash >>> 16), 0x85EBCA6B); + hash = Math.imul(hash ^ (hash >>> 13), 0xC2B2AE35); + hash = (hash ^ (hash >>> 16)) >>> 0; + const { slots, slotsBase, mask, records, recordsBase } = source; + const recordWords = stride; + let bucket = -1; + for (let slot = hash & mask; ; slot = (slot + 1) & mask) { + const filled = slots[slotsBase + 2 * slot + 1]; + if (filled === 0) break; + if (slots[slotsBase + 2 * slot] !== hash) continue; + const base = recordsBase + (filled - 1) * recordWords + 4; + let same = true; + for (let k = 0; k < words && same; k++) same = records[base + k] === key[k]; + if (same) { + bucket = filled - 1; + break; + } + } + list.a = bucket; + list.b = bucket < 0 ? 0 : records[recordsBase + bucket * recordWords + 1]; + list.c = bucket < 0 ? 0 : records[recordsBase + bucket * recordWords + 2]; + } + else { + list.c = list.d = 0; + list.h = 0; + let low = bound(plan.prefixWords, false); + let high = bound(plan.prefixWords, true); + if (plan.comparisons !== null) { + let unequalLow = -1; + let unequalHigh = -1; + for (const comparison of plan.comparisons) { + next = encode(values, comparison.values, next); + switch (comparison.operator) { + case ">=": low = Math.max(low, bound(comparison.words, false)); break; + case ">": low = Math.max(low, bound(comparison.words, true)); break; + case "<": high = Math.min(high, bound(comparison.words, false)); break; + case "<=": high = Math.min(high, bound(comparison.words, true)); break; + case "==": low = Math.max(low, bound(comparison.words, false)); high = Math.min(high, bound(comparison.words, true)); break; + case "!=": unequalLow = bound(comparison.words, false); unequalHigh = bound(comparison.words, true); break; + default: break; + } + } + if (high < low) high = low; + if (unequalLow >= 0) { + list.c = Math.max(low, Math.min(high, unequalHigh)); + list.d = high; + high = Math.min(high, Math.max(low, unequalLow)); + if (list.c >= list.d) list.c = list.d = 0; + } + } + list.a = low; + list.b = high; + } + row(first, list, 0); + }, + length, + changed: list => { + const { since } = source; + if (source.kind === "hash") { + const bucket = list.a; + if (bucket < 0) return source.cleared > since; + if (source.ticks[source.ticksBase + bucket] > since) return true; + for (let k = 0; k < source.watched.length; k++) if (source.watched[k][source.watchedBases[k] + bucket] > since) return true; + return false; + } + if (source.blocks === 0) return source.cleared > since; + return runChanged(list.a, list.b) || (list.d > list.c && runChanged(list.c, list.d)); + }, + row, + leaf: (target, leaf) => { + if (target.f !== 1) return NaN; + const view = leafView(target.a, leaf); + const found = setup.rowLeaves[leaf]; + if (view === undefined || found === null || found === undefined) return NaN; + const value = view[target.b * found.unitStride + found.unitOffset]; + return found.boolean ? value !== 0 : value; + }, + entity: target => { + if (target.f !== 1) return NaN; + return archetypeAt(target.a)?.entities[target.b] ?? NaN; + }, + }; +}; diff --git a/packages/ecs/src/implementation/world/index-sorted.test.ts b/packages/ecs/src/implementation/world/index-sorted.test.ts new file mode 100644 index 000000000..1cadaeacc --- /dev/null +++ b/packages/ecs/src/implementation/world/index-sorted.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { blockAt, blockBase, coverSortedEntities, createSortedTable, firstPosition, insertSorted, removeSorted, stampWatched, treeMaximum, watchSortedComponent, type SortedTable } from "./index-sorted.js"; +import { createMemory } from "./memory.js"; + +/** Whether every derived array matches the blocks: positions, fences, the Fenwick tree and the segment trees. */ +const consistent = (table: SortedTable): string | null => { + let position = 0; + for (let k = 0; k < table.blocks; k++) { + const block = table.order[k]; + if (table.positions[block] !== k) return `position of block ${block}`; + if (firstPosition(table, k) !== position) return `first position at ${k}`; + const length = table.lengths[block]; + if (length === 0) return `empty block at ${k}`; + if (table.fences[k * (table.words + 1) + table.words] !== table.entries[blockBase(table, block) + (length - 1) * table.stride + table.words]) return `fence at ${k}`; + for (let e = 0; e < length; e++) if (blockAt(table, position + e) !== k) return `block at ${position + e}`; + if (treeMaximum(table, table.tree, k, k) !== table.ticks[block]) return `tick at ${k}`; + for (const watch of table.watched.values()) if (treeMaximum(table, watch.tree, k, k) !== watch.ticks[block]) return `watched tick at ${k}`; + position += length; + } + if (position !== table.size) return "size"; + let newest = 0; + for (let k = 0; k < table.blocks; k++) newest = Math.max(newest, table.ticks[table.order[k]]); + if (table.blocks > 0 && table.tree[1] !== newest) return "root"; + return null; +}; + +/** The entity indexes in order. */ +const listed = (table: SortedTable): number[] => { + const found: number[] = []; + for (let k = 0; k < table.blocks; k++) { + const block = table.order[k]; + for (let e = 0; e < table.lengths[block]; e++) found.push(table.entries[blockBase(table, block) + e * table.stride + table.words]); + } + return found; +}; + +describe("a sorted table without key words", () => { + it("keeps its positions, fences and trees as blocks split and drop anywhere in the order", () => { + const table = createSortedTable(createMemory(), 0, []); + coverSortedEntities(table, 10000); + watchSortedComponent(table, 7, 0); + const key = new Uint32Array(1); + const held = new Set(); + let state = 12345; + const next = (): number => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; + for (let tick = 1; tick <= 400; tick++) { + // Bursts at the end, in the middle and at the start: scattered insertions, and removals of a run, which empty blocks. + const from = Math.floor(next() * 3) * 3000; + if (next() < 0.6) { + for (let k = 0; k < 300; k++) { + const entity = 1 + from + Math.floor(next() * 3000); + if (held.has(entity)) continue; + insertSorted(table, key, 0, [], entity, 0, entity, tick); + held.add(entity); + } + } + else { + const start = 1 + from + Math.floor(next() * 2600); + for (let entity = start; entity < start + 400; entity++) { + if (!held.has(entity)) continue; + removeSorted(table, entity, tick); + held.delete(entity); + } + } + // A watched value changed in some block. + stampWatched(table, 7, Math.floor(next() * table.blocks), tick); + expect(consistent(table)).toBeNull(); + } + expect(listed(table)).toEqual([...held].sort((a, b) => a - b)); + expect(table.blocks).toBeGreaterThan(20); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-sorted.ts b/packages/ecs/src/implementation/world/index-sorted.ts new file mode 100644 index 000000000..6c82abc8b --- /dev/null +++ b/packages/ecs/src/implementation/world/index-sorted.ts @@ -0,0 +1,651 @@ +import { allocateArray, freeArray } from './arrays.js'; +import { compareObjects, type KeyPart } from './index-keys.js'; +import type { Memory } from './memory.js'; + +/** Entries a block holds at most, and how full a rebuild leaves each. */ +export const blockEntries = 128; +const builtEntries = 96; + +/** + * A sorted index: its entries in key order, then entity order, in blocks of at most `blockEntries`, each a segment of + * one buffer; `order` lists the blocks in key order. An entry is its key's words, its entity index, its archetype's id + * and its row. Positions count entries from the first: a Fenwick tree over the blocks' lengths finds a block's first + * position, and a block by position, in O(log blocks). Ticks are kept by block, with a tree of their maximum over + * ranges of blocks, so whether a range changed is O(log blocks). Every array but the trees' scratch is in the arena, + * so other threads read it where the world's thread wrote it. + */ +export type SortedTable = { + readonly memory: Memory; + readonly words: number; + /** Words of an entry: the key's, then entity index, archetype and row. */ + readonly stride: number; + readonly parts: readonly KeyPart[]; + /** Whether a part's values are compared as objects rather than by their words. */ + readonly objects: boolean; + entries: Uint32Array; + /** By block id: its length and the tick an entity last entered, left or moved in it. */ + lengths: Int32Array; + ticks: Float64Array; + /** Block ids in key order, how many, and those freed; by block id, its position in `order`. */ + order: Int32Array; + positions: Int32Array; + blocks: number; + readonly freed: number[]; + /** Block ids allocated, used or freed. */ + allocated: number; + /** By position in `order`: the Fenwick tree of lengths, one-based. */ + counts: Int32Array; + /** Segment trees of ticks by position in `order`: membership, and by watched component. */ + tree: Float64Array; + readonly watched: Map; + /** Leaves of the segment trees: the least power of two at least `blocks`. */ + leaves: number; + /** + * By position in `order`: the key's words and the entity index of its block's last entry, `words + 1` each, so a + * search over blocks reads one compact array rather than a line of each block. + */ + fences: Uint32Array; + /** By entity index: its block id, or -1. */ + blockOf: Int32Array; + size: number; + /** The newest tick at which its last entry left, which a range of an empty table counts as changed at. */ + cleared: number; +}; + +/** What lookups read of a sorted table: on the world's thread the table, on a joined thread its views of it. */ +export type SortedView = Pick + & { readonly watched: ReadonlyMap }; + +const minimumBlocks = 8; + +/** + * Words of an entry: the key's, then entity index, archetype and row, padded to an even count, so shifting a block's + * entries by one moves 8-byte words, which a copy within shared memory moves several times faster than 4-byte ones. + */ +export const sortedStride = (words: number): number => (words + 4) & ~1; + +export const createSortedTable = (memory: Memory, words: number, parts: readonly KeyPart[]): SortedTable => ({ + memory, + words, + stride: sortedStride(words), + parts, + objects: parts.some(part => part.kind === "object"), + entries: allocateArray(memory, Uint32Array, minimumBlocks * blockEntries * sortedStride(words)), + lengths: allocateArray(memory, Int32Array, minimumBlocks).fill(0), + ticks: allocateArray(memory, Float64Array, minimumBlocks).fill(0), + order: allocateArray(memory, Int32Array, minimumBlocks), + positions: allocateArray(memory, Int32Array, minimumBlocks), + blocks: 0, + freed: [], + allocated: 0, + counts: allocateArray(memory, Int32Array, minimumBlocks + 1).fill(0), + tree: allocateArray(memory, Float64Array, 2 * minimumBlocks).fill(0), + watched: new Map(), + leaves: minimumBlocks, + fences: allocateArray(memory, Uint32Array, minimumBlocks * (words + 1)), + blockOf: allocateArray(memory, Int32Array, 64).fill(-1), + size: 0, + cleared: 0, +}); + +export const releaseSortedTable = (table: SortedTable): void => { + const { memory } = table; + for (const array of [table.entries, table.lengths, table.ticks, table.order, table.positions, table.counts, table.tree, table.fences, table.blockOf]) freeArray(memory, array); + for (const { ticks, tree } of table.watched.values()) { + freeArray(memory, ticks); + freeArray(memory, tree); + } + table.watched.clear(); +}; + +const regrow = (memory: Memory, array: T, constructor: { new(buffer: ArrayBufferLike, byteOffset: number, length: number): T; readonly BYTES_PER_ELEMENT: number }, length: number, used: number, fill: number): T => { + const grown = allocateArray(memory, constructor, length); + grown.set(array.subarray(0, used)); + grown.fill(fill, used); + freeArray(memory, array); + return grown; +}; + +/** Grows what's by entity index to cover `length` indexes. */ +export const coverSortedEntities = (table: SortedTable, length: number): void => { + if (table.blockOf.length >= length) return; + table.blockOf = regrow(table.memory, table.blockOf, Int32Array, Math.max(length, 2 * table.blockOf.length), table.blockOf.length, -1); +}; + +/** Grows what's by block id and by position to hold `count` blocks. */ +const coverBlocks = (table: SortedTable, count: number): void => { + const capacity = table.lengths.length; + if (capacity >= count) return; + const length = Math.max(count, 2 * capacity); + const { memory, stride } = table; + table.entries = regrow(memory, table.entries, Uint32Array, length * blockEntries * stride, capacity * blockEntries * stride, 0); + table.lengths = regrow(memory, table.lengths, Int32Array, length, capacity, 0); + table.ticks = regrow(memory, table.ticks, Float64Array, length, capacity, 0); + table.order = regrow(memory, table.order, Int32Array, length, capacity, 0); + table.positions = regrow(memory, table.positions, Int32Array, length, capacity, 0); + table.fences = regrow(memory, table.fences, Uint32Array, length * (table.words + 1), capacity * (table.words + 1), 0); + for (const watch of table.watched.values()) watch.ticks = regrow(memory, watch.ticks, Float64Array, length, capacity, 0); +}; + +/** A new block id, its length 0 and its tick `tick`. */ +const allocateBlock = (table: SortedTable, tick: number): number => { + const reused = table.freed.pop(); + const block = reused ?? table.allocated++; + coverBlocks(table, table.allocated); + table.lengths[block] = 0; + table.ticks[block] = tick; + for (const { ticks } of table.watched.values()) ticks[block] = tick; + return block; +}; + +/** The first word of block `block`'s entries. */ +export const blockBase = (table: SortedView, block: number): number => block * blockEntries * table.stride; + +// Fenwick tree of the blocks' lengths, by position in `order`. + +/** Rebuilds the Fenwick tree and the segment trees from the blocks in `order`, as a block was added or removed. */ +const reindex = (table: SortedTable): void => { + const { blocks, order, lengths } = table; + if (table.counts.length < blocks + 1) { + freeArray(table.memory, table.counts); + table.counts = allocateArray(table.memory, Int32Array, Math.max(blocks + 1, 2 * table.counts.length)); + } + const { counts, positions } = table; + counts.fill(0, 0, blocks + 1); + for (let k = 0; k < blocks; k++) { + positions[order[k]] = k; + fence(table, k); + } + for (let k = 0; k < blocks; k++) { + const node = k + 1; + counts[node] += lengths[order[k]]; + const parent = node + (node & -node); + if (parent <= blocks) counts[parent] += counts[node]; + } + let leaves = minimumBlocks; + while (leaves < blocks) leaves *= 2; + if (leaves !== table.leaves || table.tree.length !== 2 * leaves) { + table.leaves = leaves; + freeArray(table.memory, table.tree); + table.tree = allocateArray(table.memory, Float64Array, 2 * leaves); + for (const watch of table.watched.values()) { + freeArray(table.memory, watch.tree); + watch.tree = allocateArray(table.memory, Float64Array, 2 * leaves); + } + } + buildTree(table, table.tree, table.ticks); + for (const watch of table.watched.values()) buildTree(table, watch.tree, watch.ticks); +}; + +/** + * `reindex` for blocks at positions from `k` on, where a block was added or dropped, those before unchanged; `end` is + * past the last position either before or after. O(blocks after `k`), so a block split or dropped near the end of the + * order, as entities spawned and despawned at the end of entity order are, costs little. + */ +const reindexFrom = (table: SortedTable, k: number, end: number): void => { + const { blocks, order, lengths } = table; + let leaves = minimumBlocks; + while (leaves < blocks) leaves *= 2; + if (table.counts.length < blocks + 1 || leaves !== table.leaves) { + reindex(table); + return; + } + const { counts, positions, tree } = table; + for (let p = k; p < blocks; p++) { + positions[order[p]] = p; + fence(table, p); + } + // Each Fenwick node from `k` on: its block's length and its children's sums, those before `k` unchanged. + for (let node = k + 1; node <= blocks; node++) { + let sum = lengths[order[node - 1]]; + const low = node - (node & -node); + for (let child = node - 1; child > low; child -= child & -child) sum += counts[child]; + counts[node] = sum; + } + refreshTree(table, tree, table.ticks, k, end); + for (const watch of table.watched.values()) refreshTree(table, watch.tree, watch.ticks, k, end); +}; + +/** A segment tree's leaves from position `k` to before `end` again, and their ancestors. */ +const refreshTree = (table: SortedTable, tree: Float64Array, ticks: Float64Array, k: number, end: number): void => { + const { leaves, blocks, order } = table; + for (let p = k; p < end; p++) tree[leaves + p] = p < blocks ? ticks[order[p]] : 0; + for (let low = (leaves + k) >> 1, high = (leaves + end - 1) >> 1; low >= 1; low >>= 1, high >>= 1) { + for (let node = low; node <= high; node++) tree[node] = Math.max(tree[2 * node], tree[2 * node + 1]); + } +}; + +/** A segment tree of the maximum of `ticks` by block, over positions in `order`. */ +const buildTree = (table: SortedTable, tree: Float64Array, ticks: Float64Array): void => { + const { leaves, blocks, order } = table; + tree.fill(0); + for (let k = 0; k < blocks; k++) tree[leaves + k] = ticks[order[k]]; + for (let node = leaves - 1; node >= 1; node--) tree[node] = Math.max(tree[2 * node], tree[2 * node + 1]); +}; + +/** Copies the last entry's key and entity index of the block at position `k` into its fence. */ +const fence = (table: SortedTable, k: number): void => { + const block = table.order[k]; + const length = table.lengths[block]; + if (length === 0) return; + const { entries, fences, words } = table; + const from = blockBase(table, block) + (length - 1) * table.stride; + const to = k * (words + 1); + for (let w = 0; w <= words; w++) fences[to + w] = entries[from + w]; +}; + +/** Adds `delta` to the length of the block at position `k`. */ +const addCount = (table: SortedTable, k: number, delta: number): void => { + const { counts, blocks } = table; + for (let node = k + 1; node <= blocks; node += node & -node) counts[node] += delta; +}; + +/** The position of the first entry of the block at position `k`: the lengths of the blocks before it. */ +export const firstPosition = (table: SortedView, k: number): number => { + let sum = 0; + for (let node = k; node > 0; node -= node & -node) sum += table.counts[node]; + return sum; +}; + +/** The position in `order` of the block holding entry position `position`, where `position < size`; or `blocks`. */ +export const blockAt = (table: SortedView, position: number): number => { + const { counts, blocks } = table; + let k = 0; + let remaining = position; + let step = 1; + while (step * 2 <= blocks) step *= 2; + for (; step > 0; step >>= 1) { + const next = k + step; + if (next <= blocks && counts[next] <= remaining) { + k = next; + remaining -= counts[next]; + } + } + return k; +}; + +/** Stamps the block at position `k`, and its tree. */ +const stamp = (table: SortedTable, k: number, tick: number): void => { + table.ticks[table.order[k]] = tick; + const { tree } = table; + for (let node = table.leaves + k; node >= 1; node >>= 1) if (tree[node] < tick) tree[node] = tick; +}; + +/** Stamps a watched component's tick of the block at position `k`, and its tree. */ +export const stampWatched = (table: SortedTable, component: number, k: number, tick: number): void => { + const watch = table.watched.get(component); + if (watch === undefined || k >= table.blocks) return; + watch.ticks[table.order[k]] = tick; + const { tree } = watch; + for (let node = table.leaves + k; node >= 1; node >>= 1) if (tree[node] < tick) tree[node] = tick; +}; + +/** The largest of a tree's ticks over positions `from` to `to`, inclusive. */ +export const treeMaximum = (table: SortedView, tree: Float64Array, from: number, to: number): number => { + let maximum = 0; + for (let low = from + table.leaves, high = to + table.leaves + 1; low < high; low >>= 1, high >>= 1) { + if ((low & 1) === 1) maximum = Math.max(maximum, tree[low++]); + if ((high & 1) === 1) maximum = Math.max(maximum, tree[--high]); + } + return maximum; +}; + +/** The positions in `order` of the blocks of a watched component's changes, folded into its tree; called once a refresh stamped them. */ +export const refreshWatchedTree = (table: SortedTable, component: number): void => { + const watch = table.watched.get(component); + if (watch !== undefined) buildTree(table, watch.tree, watch.ticks); +}; + +// Comparing keys. + +/** + * Compares the first `count` words of a key in `words` at `base`, an entry's or a fence's, with `probe`'s: by word, + * an object part by its value, the probe's being `values[part]`. Negative when the key is before the probe. + */ +export const compareKey = (table: SortedView, words: Uint32Array, base: number, probe: Uint32Array, values: readonly unknown[], count: number): number => { + if (!table.objects) { + for (let k = 0; k < count; k++) { + const a = words[base + k]; + const b = probe[k]; + if (a !== b) return a < b ? -1 : 1; + } + return 0; + } + const { parts } = table; + for (let p = 0; p < parts.length; p++) { + const part = parts[p]; + if (part.first >= count) return 0; + if (part.kind === "object") { + const order = compareObjects(part.interned.values[words[base + part.first]], values[p]); + if (order !== 0) return order; + continue; + } + if (part.kind === "constant") continue; + for (let k = part.first, end = Math.min(count, part.first + part.words); k < end; k++) { + const a = words[base + k]; + const b = probe[k]; + if (a !== b) return a < b ? -1 : 1; + } + } + return 0; +}; + +/** A key and entity index in `words` at `base` compared with a probe of a whole key and an entity index, as their order is: key, then entity. */ +const compareEntry = (table: SortedTable, words: Uint32Array, base: number, probe: Uint32Array, values: readonly unknown[], entity: number): number => { + const order = compareKey(table, words, base, probe, values, table.words); + if (order !== 0) return order; + const other = words[base + table.words]; + return other === entity ? 0 : other < entity ? -1 : 1; +}; + +/** + * The first position whose entry's first `count` key words are after `probe`'s (`after`) or not before them: by + * binary search over the fences, then within a block. Returns the block's position in `order` and the offset in it, + * in `found`. + */ +export const bound = (table: SortedView, probe: Uint32Array, values: readonly unknown[], count: number, after: boolean, found: { k: number; offset: number }): void => { + if (!table.objects) { + boundWords(table, probe, count, after ? 1 : 0, found); + return; + } + const { order, lengths, stride, fences, entries } = table; + const fenceWords = table.words + 1; + // The first block whose last entry passes, since every entry before it doesn't. + let low = 0; + let high = table.blocks; + while (low < high) { + const middle = (low + high) >>> 1; + const compared = compareKey(table, fences, middle * fenceWords, probe, values, count); + if (after ? compared > 0 : compared >= 0) high = middle; + else low = middle + 1; + } + found.k = low; + if (low === table.blocks) { + found.offset = 0; + return; + } + const block = order[low]; + const base = blockBase(table, block); + let first = 0; + let last = lengths[block]; + while (first < last) { + const middle = (first + last) >>> 1; + const compared = compareKey(table, entries, base + middle * stride, probe, values, count); + if (after ? compared > 0 : compared >= 0) last = middle; + else first = middle + 1; + } + found.offset = first; +}; + +/** + * `bound` for keys of words alone: a key passes when its first `count` words, compared word by word, are after the + * probe's, or with `after` 0, not before them. + */ +const boundWords = (table: SortedView, probe: Uint32Array, count: number, after: number, found: { k: number; offset: number }): void => { + const { order, lengths, stride, fences, entries } = table; + const fenceWords = table.words + 1; + let low = 0; + let high = table.blocks; + while (low < high) { + const middle = (low + high) >>> 1; + const base = middle * fenceWords; + // Equal keys pass unless `after`. + let passes = after === 0; + for (let k = 0; k < count; k++) { + const a = fences[base + k]; + const b = probe[k]; + if (a !== b) { + passes = a > b; + break; + } + } + if (passes) high = middle; + else low = middle + 1; + } + found.k = low; + if (low === table.blocks) { + found.offset = 0; + return; + } + const base = blockBase(table, order[low]); + let first = 0; + let last = lengths[order[low]]; + while (first < last) { + const middle = (first + last) >>> 1; + const at = base + middle * stride; + let passes = after === 0; + for (let k = 0; k < count; k++) { + const a = entries[at + k]; + const b = probe[k]; + if (a !== b) { + passes = a > b; + break; + } + } + if (passes) last = middle; + else first = middle + 1; + } + found.offset = first; +}; + +/** The position of `found`'s block and offset. */ +export const positionOf = (table: SortedTable, found: { readonly k: number; readonly offset: number }): number => found.k >= table.blocks ? table.size : firstPosition(table, found.k) + found.offset; + +// Changing entries. + +/** + * Where entity index `entity` is in its block, or -1: by binary search in an index without a key, whose entries are in + * entity order, else by scanning it. + */ +const offsetIn = (table: SortedTable, block: number, entity: number): number => { + const { entries, stride, words } = table; + const base = blockBase(table, block) + words; + const length = table.lengths[block]; + if (words === 0) { + const at = entityPosition(entries, base, stride, length, entity); + return at < length && entries[base + at * stride] === entity ? at : -1; + } + for (let k = 0; k < length; k++) if (entries[base + k * stride] === entity) return k; + return -1; +}; + +/** The first of `length` entries from `base`, `stride` words apart and in entity order, whose entity index isn't below `entity`. */ +const entityPosition = (entries: Uint32Array, base: number, stride: number, length: number, entity: number): number => { + let first = 0; + let last = length; + while (first < last) { + const middle = (first + last) >>> 1; + if (entries[base + middle * stride] < entity) first = middle + 1; + else last = middle; + } + return first; +}; + +/** The words of the entry at `base`'s key equal `key`'s from `at`. */ +export const sameStoredKey = (table: SortedTable, base: number, key: Uint32Array, at: number): boolean => { + for (let k = 0; k < table.words; k++) if (table.entries[base + k] !== key[at + k]) return false; + return true; +}; + +/** The block entity index `entity` is in, the offset of its entry there, or -1 for none. */ +export const entryOf = (table: SortedTable, entity: number): number => { + const block = table.blockOf[entity]; + if (block < 0) return -1; + const offset = offsetIn(table, block, entity); + return offset < 0 ? -1 : blockBase(table, block) + offset * table.stride; +}; + +/** Removes entity index `entity`'s entry, stamping its block; a block left empty is dropped, its neighbors stamped. */ +export const removeSorted = (table: SortedTable, entity: number, tick: number): void => { + const block = table.blockOf[entity]; + if (block < 0) return; + const offset = offsetIn(table, block, entity); + if (offset < 0) return; + const { entries, stride } = table; + const base = blockBase(table, block); + const length = table.lengths[block]; + entries.copyWithin(base + offset * stride, base + (offset + 1) * stride, base + length * stride); + table.lengths[block] = length - 1; + table.blockOf[entity] = -1; + table.size--; + const k = table.positions[block]; + if (length - 1 > 0) { + addCount(table, k, -1); + if (offset === length - 1) fence(table, k); + stamp(table, k, tick); + return; + } + // An empty block is dropped; its neighbors are stamped, so a range that held only its entries reads as changed. + table.order.copyWithin(k, k + 1, table.blocks); + table.blocks--; + table.freed.push(block); + reindexFrom(table, k, table.blocks + 1); + if (k > 0) stamp(table, k - 1, tick); + if (k < table.blocks) stamp(table, k, tick); + if (table.blocks === 0) table.cleared = tick; +}; + +/** + * Inserts an entry for entity index `entity`, its key's words in `key` from `at`, by its key then entity index, + * stamping its block; a full block splits in two. + */ +export const insertSorted = (table: SortedTable, key: Uint32Array, at: number, values: readonly unknown[], entity: number, archetype: number, row: number, tick: number): void => { + const { stride, words } = table; + const probe = at === 0 ? key : key.subarray(at, at + words); + if (table.blocks === 0) { + const block = allocateBlock(table, tick); + table.order[0] = block; + table.blocks = 1; + reindex(table); + } + // The block whose last entry is after the new one, or the last block. + let low = 0; + let high = table.blocks; + const fenceWords = words + 1; + const { fences } = table; + while (low < high) { + const middle = (low + high) >>> 1; + if (words === 0 ? fences[middle] > entity : compareEntry(table, fences, middle * fenceWords, probe, values, entity) > 0) high = middle; + else low = middle + 1; + } + let k = Math.min(low, table.blocks - 1); + let block = table.order[k]; + if (table.lengths[block] === blockEntries) { + split(table, k, tick); + // The new one goes in the first half unless it's after the first half's last entry. + if (compareEntry(table, table.fences, k * fenceWords, probe, values, entity) < 0) k++; + block = table.order[k]; + } + const base = blockBase(table, block); + const length = table.lengths[block]; + let first = 0; + if (words === 0) first = entityPosition(table.entries, base, stride, length, entity); + else { + let last = length; + while (first < last) { + const middle = (first + last) >>> 1; + if (compareEntry(table, table.entries, base + middle * stride, probe, values, entity) > 0) last = middle; + else first = middle + 1; + } + } + const { entries } = table; + entries.copyWithin(base + (first + 1) * stride, base + first * stride, base + length * stride); + const to = base + first * stride; + for (let w = 0; w < words; w++) entries[to + w] = probe[w]; + entries[to + words] = entity; + entries[to + words + 1] = archetype; + entries[to + words + 2] = row; + table.lengths[block] = length + 1; + table.blockOf[entity] = block; + table.size++; + addCount(table, k, 1); + if (first === length) fence(table, k); + stamp(table, k, tick); +}; + +/** Splits the full block at position `k` into two halves, the second a new block after it. */ +const split = (table: SortedTable, k: number, tick: number): void => { + const block = table.order[k]; + const next = allocateBlock(table, tick); + const { entries, stride, words } = table; + const half = blockEntries >> 1; + const from = blockBase(table, block) + half * stride; + const to = blockBase(table, next); + entries.copyWithin(to, from, from + (blockEntries - half) * stride); + table.lengths[block] = half; + table.lengths[next] = blockEntries - half; + for (let e = 0; e < blockEntries - half; e++) table.blockOf[entries[to + e * stride + words]] = next; + table.ticks[next] = table.ticks[block]; + for (const { ticks } of table.watched.values()) ticks[next] = ticks[block]; + if (table.order.length < table.blocks + 1) coverBlocks(table, table.blocks + 1); + table.order.copyWithin(k + 2, k + 1, table.blocks); + table.order[k + 1] = next; + table.blocks++; + reindexFrom(table, k, table.blocks); +}; + +/** Updates where entity index `entity` is: its archetype and row, at its entry. */ +export const relocateSorted = (table: SortedTable, entity: number, archetype: number, row: number): void => { + const base = entryOf(table, entity); + if (base < 0) return; + table.entries[base + table.words + 1] = archetype; + table.entries[base + table.words + 2] = row; +}; + +/** + * Builds the table again from `count` entries in key order, then entity order: their keys' words, `words` each in + * `keys`, and entity indexes, archetypes and rows, 3 each in `listed`, by `sorted[k]`, the entry at position `k`. + * Every block's tick, and every watched tick, is `tick`. + */ +export const loadSortedTable = (table: SortedTable, count: number, keys: Uint32Array, listed: Int32Array, sorted: Int32Array, tick: number, entityCount: number): void => { + const { words, stride } = table; + coverSortedEntities(table, entityCount); + table.blockOf.fill(-1); + const blocks = Math.ceil(count / builtEntries); + table.freed.length = 0; + table.allocated = 0; + coverBlocks(table, blocks); + table.allocated = blocks; + table.blocks = blocks; + table.size = count; + const { entries } = table; + for (let b = 0; b < blocks; b++) { + table.order[b] = b; + const first = b * builtEntries; + const length = Math.min(builtEntries, count - first); + table.lengths[b] = length; + table.ticks[b] = tick; + for (const watch of table.watched.values()) watch.ticks[b] = tick; + const base = blockBase(table, b); + for (let e = 0; e < length; e++) { + const entry = sorted[first + e]; + const to = base + e * stride; + for (let w = 0; w < words; w++) entries[to + w] = keys[entry * words + w]; + const entity = listed[3 * entry]; + entries[to + words] = entity; + entries[to + words + 1] = listed[3 * entry + 1]; + entries[to + words + 2] = listed[3 * entry + 2]; + table.blockOf[entity] = b; + } + } + table.cleared = tick; + reindex(table); +}; + +/** Starts keeping, by block, the newest tick a watched component may have changed in a row there, at `tick`. */ +export const watchSortedComponent = (table: SortedTable, component: number, tick: number): void => { + if (table.watched.has(component)) return; + const ticks = allocateArray(table.memory, Float64Array, table.lengths.length).fill(tick); + const tree = allocateArray(table.memory, Float64Array, 2 * table.leaves); + const watch = { ticks, tree }; + table.watched.set(component, watch); + buildTree(table, tree, ticks); +}; + +export const unwatchSortedComponent = (table: SortedTable, component: number): void => { + const watch = table.watched.get(component); + if (watch === undefined) return; + freeArray(table.memory, watch.ticks); + freeArray(table.memory, watch.tree); + table.watched.delete(component); +}; diff --git a/packages/ecs/src/implementation/world/index-workers.cases.ts b/packages/ecs/src/implementation/world/index-workers.cases.ts new file mode 100644 index 000000000..fb311d751 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-workers.cases.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./index-workers.fixture.js"; + +/** Kernels looking up through indexes across workers: the cases, over the fixture built for node or from source. */ +export const indexWorkersCases = (load: () => Promise) => describe("index lookups on workers", () => { + it.each([[true], [false]])("give what the main thread alone gives, WASM %s", async wasm => { + const fixture = await load(); + const alone = await fixture.simulate(0, wasm); + const one = await fixture.simulate(1, wasm); + const three = await fixture.simulate(3, wasm); + expect(one.rows).toEqual(alone.rows); + expect(three.rows).toEqual(alone.rows); + expect(three.tasks).toBeGreaterThan(0); + // Targets' tallies through the index without a key: its length in millions, then a stride of its weights. + expect(three.rows.filter(row => /^\[\d+,-?\d+,\d{7,}\]$/.test(row)).length).toBeGreaterThan(900); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts new file mode 100644 index 000000000..d8c962b8f --- /dev/null +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -0,0 +1,191 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { readEntity, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { countingWorkers } from "./platforms.test-helper.js"; + +/** Scenarios of kernels looking up through indexes across workers, run built in node and from source in chromium. */ + +const cell = Symbol("indexWorkersTest/cell"); +const weight = Symbol("indexWorkersTest/weight"); +const total = Symbol("indexWorkersTest/total"); +const count = Symbol("indexWorkersTest/count"); +const changes = Symbol("indexWorkersTest/changes"); +const lane = Symbol("indexWorkersTest/lane"); +const rank = Symbol("indexWorkersTest/rank"); +const seen = Symbol("indexWorkersTest/seen"); +const serial = Symbol("indexWorkersTest/serial"); +const target = Symbol("indexWorkersTest/target"); +const tally = Symbol("indexWorkersTest/tally"); + +declare module "@adobe/ecs" { + interface Components { + [cell]: { type: readonly [number, number] }; + [weight]: { type: number }; + [total]: { type: number }; + [count]: { type: number }; + [changes]: { type: number }; + [lane]: { type: number }; + [rank]: { type: number }; + [seen]: { type: Entity }; + [serial]: { type: number }; + [target]: { type: number }; + [tally]: { type: number }; + } +} +Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); +Component.register(weight, { type: { type: "number" } }); +Component.register(total, { type: { type: "number" } }); +Component.register(count, { type: { type: "number" } }); +Component.register(changes, { type: { type: "number" } }); +Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); +Component.register(rank, { type: { type: "number" } }); +Component.register(seen, { type: Entity.schema }); +Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); +Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); +Component.register(tally, { type: { type: "number" } }); + +const grid = Index.create({ name: "indexWorkersTest/grid", key: [cell], components: { has: [weight] } }); +const lanes = Index.create({ name: "indexWorkersTest/lanes", key: [lane, rank], sorted: true }); +const serials = Index.create({ name: "indexWorkersTest/serials", key: [serial], unique: true, components: { has: [weight] } }); +const weighted = Index.create({ name: "indexWorkersTest/weighted", key: [], components: { has: [weight] } }); + +/** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ +const neighbors = System.create({ + name: "indexWorkersTest/neighbors", kernel: "cpu", + components: { read: [cell], write: [total, count] }, + lookup: { near: { index: grid, read: [weight] } }, +}, (row, { lookup }) => { + const [x, y] = row[cell]; + let sum = 0; + let found = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const near = lookup.near([x + dx, y + dy]); + for (let i = 0; i < near.length; i++) { + sum += near.at(i)[weight]; + found++; + } + } + } + row[total] = sum; + row[count] = found; +}); + +/** Counts runs whose list of the row's own cell changed. */ +const watch = System.create({ + name: "indexWorkersTest/watch", kernel: "cpu", + components: { read: [cell], write: [changes] }, + lookup: { near: { index: grid, changed: [weight] } }, + after: [neighbors], +}, (row, { lookup }) => { + const [x, y] = row[cell]; + if (lookup.near([x, y]).changed) row[changes] = row[changes] + 1; +}); + +/** How many rows of the row's lane have a rank within 5 of its own, and the first of them. */ +const window = System.create({ + name: "indexWorkersTest/window", kernel: "cpu", + components: { read: [lane, rank], write: [count, seen] }, + lookup: { inLane: { index: lanes } }, + after: [watch], +}, (row, { lookup }) => { + const r = row[rank]; + const near = lookup.inLane(row[lane], { ">=": r - 5, "<": r + 5 }); + row[count] = near.length; + row[seen] = near.length === 0 ? Entity.none : near.at(0).entity; +}); + +/** The weight of the entity whose serial is the row's target, or -1. */ +const follow = System.create({ + name: "indexWorkersTest/follow", kernel: "cpu", + components: { read: [target], write: [total] }, + lookup: { bySerial: { index: serials, read: [weight] } }, + after: [window], +}, (row, { lookup }) => { + const found = lookup.bySerial(row[target]); + row[total] = found === null ? -1 : found[weight]; +}); + +/** Through an index without a key: how many entities it holds, a stride of their weights, and whether it changed. */ +const census = System.create({ + name: "indexWorkersTest/census", kernel: "cpu", + components: { read: [target], write: [tally] }, + lookup: { weighted: { index: weighted, read: [weight], changed: [weight] } }, + after: [follow], +}, (row, { lookup }) => { + const all = lookup.weighted(); + let sum = 0; + for (let i = row[target] % 97; i < all.length; i += 97) sum += all.at(i)[weight]; + row[tally] = all.length * 1000000 + sum * 2 + (all.changed ? 1 : 0); +}); + +const tick = System.create({ name: "indexWorkersTest/tick", systems: [neighbors, watch, window, follow, census] }); + +const seed = System.create({ name: "indexWorkersTest/seed" }, ({ commands }) => { + for (let k = 0; k < 3000; k++) { + commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [(k * 7) % 31, (k * 3) % 29], [weight]: k % 9, [total]: 0, [count]: 0, [changes]: 0, [serial]: k }); + } + for (let k = 0; k < 2000; k++) { + commands.spawn([lane, rank, count, seen])({ [lane]: k % 5, [rank]: (k * 37) % 1009, [count]: 0, [seen]: Entity.none }); + } + for (let k = 0; k < 1000; k++) commands.spawn([target, total, tally])({ [target]: (k * 13) % 3500, [total]: 0, [tally]: 0 }); +}); + +/** Moves some cells, changes some weights and ranks, despawns some serials and spawns more: a different input each time. */ +const stir = System.create({ name: "indexWorkersTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [cell, weight] } }, (row, { input }) => { + const [x, y] = row[cell]; + if ((x + y + input.round) % 11 === 0) row[cell] = [(x + 1) % 31, y]; + if ((x * y + input.round) % 13 === 0) row[weight] = row[weight] + 1; +}); +const rerank = System.create({ name: "indexWorkersTest/rerank", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [rank] } }, (row, { input }) => { + if ((row[rank] + input.round) % 7 === 0) row[rank] = (row[rank] * 3 + 1) % 1009; +}); +const churn = System.create({ name: "indexWorkersTest/churn", input: { type: "object", properties: { round: { type: "integer" } } }, components: { read: [serial] } }, (row, { input, commands }) => { + if ((row[serial] + input.round) % 17 === 0) commands.despawn(row.entity); + if (row[serial] % 101 === input.round) { + commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [input.round, row[serial] % 29], [weight]: 4, [total]: 0, [count]: 0, [changes]: 0, [serial]: 3000 + row[serial] + input.round }); + } +}); + +/** Every value the kernels wrote over 6 rounds, sorted, since entity ids depend on reservation interleaving, and the run tasks and windows posted to workers. */ +export const simulate = async (threads: number, wasm: boolean): Promise<{ readonly rows: readonly string[]; readonly tasks: number }> => { + const counting = countingWorkers(); + const ecs = createWorld({ [workers]: threads }, { minimumChunk: 64, wasm, pool: counting.platform }); + try { + await ecs.run(seed); + const posted = (): number => counting.posted("run") + counting.posted("window"); + const before = posted(); + for (let round = 0; round < 6; round++) { + await ecs.run(tick); + await ecs.run(stir, { round }); + await ecs.run(rerank, { round }); + await ecs.run(churn, { round }); + } + await ecs.run(tick); + const tasks = posted() - before; + const cells = await Promise.all((await select(ecs, { required: [cell] })).map(async entity => { + const values = await readEntity(ecs, entity, [cell, weight, total, count, changes]); + return values === null ? null : [values[cell], values[weight], values[total], values[count], values[changes]]; + })); + const ranked = await Promise.all((await select(ecs, { required: [rank] })).map(async entity => { + const values = await readEntity(ecs, entity, [lane, rank, count, seen]); + if (values === null) return null; + // The first row's lane and rank name it, entity ids aside. + const first = values[seen] === Entity.none ? null : await readEntity(ecs, values[seen], [lane, rank]); + return [values[lane], values[rank], values[count], first === null ? null : [first[lane], first[rank]]]; + })); + const targets = await Promise.all((await select(ecs, { required: [target] })).map(async entity => { + const values = await readEntity(ecs, entity, [target, total, tally]); + return values === null ? null : [values[target], values[total], values[tally]]; + })); + const rows = [...cells, ...ranked, ...targets].map(row => JSON.stringify(row)); + return { rows: rows.sort(), tasks }; + } + finally { + await ecs.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts new file mode 100644 index 000000000..c2bf14116 --- /dev/null +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -0,0 +1,731 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import { tablesAfterCollecting } from "./lifetime.test-helper.js"; +import { backToBack } from "./loop.js"; +import { runReported } from "./reports.test-helper.js"; + +const cell = Symbol("indexesTest/cell"); +const weight = Symbol("indexesTest/weight"); +const label = Symbol("indexesTest/label"); +const serverId = Symbol("indexesTest/serverId"); +const owner = Symbol("indexesTest/owner"); +const hidden = Symbol("indexesTest/hidden"); +const order = Symbol("indexesTest/order"); + +declare module "@adobe/ecs" { + interface Components { + [cell]: { type: readonly [number, number] }; + [weight]: { type: number }; + [label]: { type: string }; + [serverId]: { type: number }; + [owner]: { type: Entity; reference: "weak" }; + [hidden]: { type: true }; + [order]: { type: number }; + } +} + +Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); +Component.register(weight, { type: { type: "number" } }); +Component.register(label, { type: { type: "string" } }); +Component.register(serverId, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); +Component.register(owner, { type: Entity.schema, reference: "weak" }); +Component.register(hidden, { type: { const: true } }); +Component.register(order, { type: { type: "number" } }); + +const grid = Index.create({ name: "indexesTest/grid", key: [cell], components: { has: [weight], not: [hidden] } }); +const byServerId = Index.create({ name: "indexesTest/byServerId", key: [serverId], unique: true }); +const byLabel = Index.create({ name: "indexesTest/byLabel", key: [label] }); +const children = Index.create({ name: "indexesTest/children", key: [owner] }); + +const spawnCells = System.create({ name: "indexesTest/spawnCells", input: { type: "object", properties: { cells: { type: "array", items: { type: "array", items: { type: "integer" } } } } } }, ({ input, commands }) => { + input.cells.forEach(([x, y], k) => commands.spawn([cell, weight])({ [cell]: [x ?? 0, y ?? 0], [weight]: k })); +}); + +/** What a lookup of cell `at` holds, as entities and weights, in its order, and whether it changed. */ +let seen: { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly changed: boolean; readonly length: number } | null = null; + +const look = System.create({ + name: "indexesTest/look", + input: { type: "object", properties: { x: { type: "integer" }, y: { type: "integer" } } }, + lookup: { near: { index: grid, read: [weight] } }, +}, ({ input, lookup }) => { + const near = lookup.near([input.x, input.y]); + const entities: Entity[] = []; + const weights: number[] = []; + for (let i = 0; i < near.length; i++) { + entities.push(near.at(i).entity); + weights.push(near.at(i)[weight]); + } + seen = { entities, weights, changed: near.changed, length: near.length }; +}); + +/** Moves every entity in cell [0, 0] to [1, 0], before `lookAfter` in the same run. */ +const shift = System.create({ name: "indexesTest/shift", components: { write: [cell] } }, row => { + if (row[cell][0] === 0 && row[cell][1] === 0) row[cell] = [1, 0]; +}); +let afterShift: number | null = null; +const lookAfter = System.create({ name: "indexesTest/lookAfter", lookup: { near: { index: grid } }, after: [shift] }, ({ lookup }) => { + afterShift = lookup.near([1, 0]).length; +}); +const shiftThenLook = System.create({ name: "indexesTest/shiftThenLook", systems: [shift, lookAfter] }); + +const entitiesOf = (world: CreatedWorld): Promise => Observe.toPromise(Observe.select(world, { required: [cell] })); + +describe("hash indexes on the main thread", () => { + it("find a bucket's rows by key, in entity order, and nothing for a key no entity has", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [1, 1], [0, 0], [2, 2], [0, 0]] }); + const all = await entitiesOf(world); + await world.run(look, { x: 0, y: 0 }); + expect(seen?.entities).toEqual([all[0], all[2], all[4]]); + expect(seen?.weights).toEqual([0, 2, 4]); + await world.run(look, { x: 5, y: 5 }); + expect(seen?.length).toBe(0); + }); + it("see keys written by systems ordered before them in the same run", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [0, 0], [1, 0]] }); + await world.run(shiftThenLook); + expect(afterShift).toBe(3); + await world.run(look, { x: 0, y: 0 }); + expect(seen?.length).toBe(0); + }); + it("follow spawns, despawns and archetype moves at sync points, and hold only entities of their components", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [0, 0], [0, 0]] }); + const [a, b, c] = await entitiesOf(world); + await world.run(System.create(({ commands }) => { + commands.despawn(a); + commands.add(b, hidden); + commands.spawn([cell, weight])({ [cell]: [0, 0], [weight]: 9 }); + commands.spawn([cell])({ [cell]: [0, 0] }); + })); + await world.run(look, { x: 0, y: 0 }); + expect(seen?.weights).toEqual([2, 9]); + expect(seen?.entities[0]).toBe(c); + await world.run(System.create(({ commands }) => { commands.remove(b, hidden); })); + await world.run(look, { x: 0, y: 0 }); + expect(seen?.weights).toEqual([1, 2, 9]); + }); + it("report changed only when an entity entered, left or moved in the list since the system last ran", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [0, 0], [1, 1]] }); + await world.run(look, { x: 0, y: 0 }); + expect(seen?.changed).toBe(true); + await world.run(look, { x: 0, y: 0 }); + expect(seen?.changed).toBe(false); + // Another bucket changing isn't this one changing, and a list compares with the system's last run, whatever it looked up then. + const [, , third] = await entitiesOf(world); + await world.run(System.create(({ commands }) => { commands.add(third, cell, [2, 2]); })); + await world.run(look, { x: 0, y: 0 }); + expect(seen?.changed).toBe(false); + await world.run(look, { x: 2, y: 2 }); + expect(seen).toMatchObject({ changed: false, length: 1 }); + await world.run(System.create(({ commands }) => { commands.add(third, cell, [0, 0]); })); + await world.run(look, { x: 2, y: 2 }); + expect(seen).toMatchObject({ changed: true, length: 0 }); + await world.run(System.create(({ commands }) => { commands.add(third, cell, [5, 5]); })); + await world.run(look, { x: 0, y: 0 }); + expect(seen).toMatchObject({ changed: true, length: 2 }); + }); + it("key strings by value, and entities by their generation too", async () => { + await using world = createWorld({ [workers]: 0 }); + let found: readonly number[] = []; + const named = System.create({ input: { type: "object", properties: { name: { type: "string" } } }, lookup: { named: { index: byLabel } } }, ({ input, lookup }) => { + const list = lookup.named(input.name); + found = Array.from({ length: list.length }, (_, i) => list.at(i).entity); + }); + await world.run(System.create(({ commands }) => { + commands.spawn([label])({ [label]: "a" }); + commands.spawn([label])({ [label]: "b" }); + commands.spawn([label])({ [label]: "a" }); + })); + const labeled = await Observe.toPromise(Observe.select(world, { required: [label] })); + await world.run(named, { name: "a" }); + expect(found).toEqual([labeled[0], labeled[2]]); + await world.run(named, { name: "c" }); + expect(found).toEqual([]); + const parent = labeled[1]; + let held: readonly Entity[] = []; + const kids = System.create({ input: { type: "object", properties: { of: { type: "number" } } }, lookup: { of: { index: children } } }, ({ input, lookup }) => { + const list = lookup.of(Entity.none); + void list; + const of = lookup.of(input.of === 0 ? parent : Entity.none); + held = Array.from({ length: of.length }, (_, i) => of.at(i).entity); + }); + await world.run(System.create(({ commands }) => { + commands.spawn([owner])({ [owner]: parent }); + commands.spawn([owner])({ [owner]: parent }); + })); + await world.run(kids, { of: 0 }); + expect(held).toHaveLength(2); + await world.run(System.create(({ commands }) => { commands.despawn(parent); })); + await world.run(kids, { of: 0 }); + expect(held).toEqual([]); + }); +}); + +describe("unique indexes", () => { + let known: Entity | null = null; + const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { known: { index: byServerId } } }, ({ input, lookup }) => { + known = lookup.known(input.id)?.entity ?? null; + }); + it("give the row of a key or null, and report two entities with one key as an index failure, keeping the lower", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create(({ commands }) => { + commands.spawn([serverId])({ [serverId]: 7 }); + commands.spawn([serverId])({ [serverId]: 8 }); + })); + const [first, second] = await Observe.toPromise(Observe.select(world, { required: [serverId] })); + const report = await runReported(world, find, { id: 7 }); + expect(report.failures).toEqual([]); + expect(known).toBe(first); + await world.run(find, { id: 9 }); + expect(known).toBe(null); + await world.run(System.create(({ commands }) => { commands.add(second, serverId, 7); })); + const conflicted = await runReported(world, find, { id: 7 }); + expect(known).toBe(first); + expect(conflicted.failures).toEqual([{ + kind: "index", + name: "indexesTest/byServerId", + message: `Index "indexesTest/byServerId" is unique, and entities ${first} and ${second} both have key 7; lookups give ${first}, the one with the lower entity index.`, + stack: null, + }]); + // Found once: the next run reports nothing. + expect((await runReported(world, find, { id: 7 })).failures).toEqual([]); + }); +}); + +describe("call sites", () => { + it("keep their own lists, valid until they run again, and rows distinct by position", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [0, 0], [1, 1]] }); + let pairs: number[] = []; + const both = System.create({ lookup: { near: { index: grid, read: [weight] } } }, ({ lookup }) => { + const a = lookup.near([0, 0]); + const b = lookup.near([1, 1]); + pairs = [a.length, b.length, a.at(0)[weight] + a.at(1)[weight], b.at(0)[weight]]; + }); + await world.run(both); + expect(pairs).toEqual([2, 1, 1, 2]); + }); + it("work as written where the plugin didn't rewrite them, making a new list each call", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [0, 0]] }); + let lengths: number[] = []; + const written = System.create({ lookup: { near: { index: grid } } }, context => { + const near = context.lookup.near; + const first = near([0, 0]); + const second = near([1, 1]); + lengths = [first.length, second.length]; + }); + await world.run(written); + expect(lengths).toEqual([2, 0]); + }); +}); + +describe("finalizers", () => { + const released: number[] = []; + Finalizer.create({ name: "indexesTest/release", components: { read: [order, cell] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { + released.push(lookup.near(row[cell]).length); + }); + it("look up through indexes as they are before the sync point applies", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create(({ commands }) => { + commands.spawn([cell, weight])({ [cell]: [3, 3], [weight]: 1 }); + commands.spawn([cell, weight, order])({ [cell]: [3, 3], [weight]: 2, [order]: 0 }); + })); + const [, ordered] = await entitiesOf(world); + await world.run(System.create(({ commands }) => { + commands.despawn(ordered); + commands.spawn([cell, weight])({ [cell]: [3, 3], [weight]: 3 }); + })); + expect(released).toEqual([2]); + }); +}); + +const keepTicking = System.create({ name: "indexesTest/keepTicking" }, () => {}); +/** Uses `grid` every tick it's added to. */ +const watchGrid = System.create({ name: "indexesTest/watchGrid", lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; }); + +describe("index lifetime", () => { + it("builds an index the first time a system uses it and releases it, arena blocks included, once none does", async () => { + const driver = backToBack(); + await using world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + await world.run(spawnCells, { cells: [[0, 0], [1, 1]] }); + // Ticks of what an index is keyed by stay once started, as any component's do. + await world.run(System.create({ components: { read: [cell], changed: [cell] } }, row => { void row[cell]; })); + const before = await tablesAfterCollecting(world); + // A system made for one run is collected, and its index with it. + await world.run(System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; })); + expect(await tablesAfterCollecting(world)).toEqual(before); + // A removed one forgets it. + const ticks = async (count: number): Promise => { + const first = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await new Promise(resolve => { + const stop = Observe.resource(world, clock)(({ tick }) => { + if (tick < first + count) return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + }; + // Ticks run only while a system is added. + world.add(keepTicking); + world.add(watchGrid); + await ticks(2); + expect((await world.tables()).indexes).toBe(before.indexes + 1); + world.remove(watchGrid); + await ticks(2); + const removed = await tablesAfterCollecting(world); + expect(removed.indexes).toBe(before.indexes); + expect(removed.journalReaders).toBe(before.journalReaders); + driver.close(); + }); +}); + +/** A seeded generator of numbers in [0, 1), so a failing round replays. */ +const random = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (Math.imul(state ^ (state >>> 15), 0x2C1B3C6D) + 0x6D2B79F5) >>> 0; + state ^= state >>> 12; + return (state >>> 0) / 0x100000000; + }; +}; + +/** Every cell a property test's entities take: sixteen, or two, so its buckets are long. */ +const allCells: readonly (readonly [number, number])[] = Array.from({ length: 16 }, (_, k) => [k % 4, k >> 2]); +let cells: readonly (readonly [number, number])[] = allCells; + +/** Each cell's entities, through `lookup`, which must be in entity order. */ +const bucketsOf = (lookup: (key: readonly [number, number]) => { readonly length: number; at(index: number): { readonly entity: Entity } }): readonly (readonly number[])[] => + allCells.map(key => { + const list = lookup(key); + return Array.from({ length: list.length }, (_, i) => list.at(i).entity); + }); + +const isOrdered = (bucket: readonly number[]): boolean => bucket.every((entity, k) => k === 0 || (bucket[k - 1] >>> 0) < (entity >>> 0)); + +let incremental: readonly (readonly number[])[] = []; +const probe = System.create({ name: "indexesTest/probe", lookup: { near: { index: grid } } }, ({ lookup }) => { incremental = bucketsOf(key => lookup.near(key)); }); + +const folded = Symbol("indexesTest/folded"); +declare module "@adobe/ecs" { + interface Components { + [folded]: { type: number }; + } +} +Component.register(folded, { type: { type: "number" } }); + +/** A list's entities folded into one number, by position: what `foldNear` writes. */ +const foldOf = (bucket: readonly number[]): number => bucket.reduce((sum, entity, i) => sum + (entity % 997) * (i + 1), 0); + +/** A kernel folding the entities of its row's own cell's list, so kernels' lookups are checked against the same index. */ +const foldNear = System.create({ + name: "indexesTest/foldNear", kernel: "cpu", + components: { read: [cell], write: [folded] }, + lookup: { near: { index: grid } }, +}, (row, { lookup }) => { + const [x, y] = row[cell]; + const near = lookup.near([x, y]); + let sum = 0; + for (let i = 0; i < near.length; i++) sum += (near.at(i).entity % 997) * (i + 1); + row[folded] = sum; +}); + +/** Moves each row whose weight is below `share` of 1000 to the cell `cell` gives it, so keys change during runs. */ +const rekey = System.create({ + name: "indexesTest/rekey", input: { type: "object", properties: { share: { type: "number" }, seed: { type: "integer" } } }, + components: { read: [weight], write: [cell] }, before: [probe], +}, (row, { input }) => { + if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[cell] = cells[(row[weight] + input.seed) % cells.length]; +}); + +describe("an incrementally refreshed hash index", () => { + it.each([[16, 12345, true], [2, 678, false]])("equals one built from scratch after random spawns, despawns, moves and key writes, its buckets in entity order, as kernels read it too, over %i cells, WASM %s", async (cellCount, seed, wasm) => { + cells = allCells.slice(0, cellCount); + await using world = createWorld({ [workers]: 0 }, { wasm }); + const next = random(seed); + const pick = (list: readonly T[]): T | undefined => list[Math.floor(next() * list.length)]; + let held = 0; + for (let step = 0; step < 80; step++) { + const living = await Observe.toPromise(Observe.select(world, { required: [cell] })); + const operations = Math.floor(next() * 60); + const plan: (readonly [string, Entity | undefined, number])[] = []; + for (let k = 0; k < operations; k++) plan.push([pick(["spawn", "spawn", "spawn", "spawn", "despawn", "hide", "show", "weigh", "unweigh", "set"]) ?? "spawn", pick(living), Math.floor(next() * 1000)]); + await world.run(System.create(({ commands }) => { + for (const [operation, entity, value] of plan) { + if (operation === "spawn") commands.spawn([cell, weight, folded])({ [cell]: cells[value % cells.length], [weight]: value, [folded]: 0 }); + else if (entity === undefined) continue; + else if (operation === "despawn") commands.despawn(entity); + else if (operation === "hide") commands.add(entity, hidden); + else if (operation === "show") commands.remove(entity, hidden); + else if (operation === "weigh") commands.add(entity, weight, value); + else if (operation === "unweigh") commands.remove(entity, weight); + else commands.add(entity, cell, cells[value % cells.length]); + } + })); + // Now and then most rows' keys change, past what a patch takes. + await world.run(System.create({ systems: [rekey, probe] }), { share: next() < 0.1 ? 0.9 : 0.05, seed: step }); + const scratch = Index.create({ key: [cell], components: { has: [weight], not: [hidden] } }); + let rebuilt: readonly (readonly number[])[] = []; + await world.run(System.create({ lookup: { near: { index: scratch } } }, ({ lookup }) => { rebuilt = bucketsOf(key => lookup.near(key)); })); + expect(incremental).toEqual(rebuilt); + expect(incremental.every(isOrdered)).toBe(true); + await world.run(foldNear); + const rows = await Observe.toPromise(Observe.select(world, { required: [folded] })); + const kernel = await Promise.all(rows.map(entity => Observe.toPromise(Observe.entity(world, entity, { required: [cell, folded] })))); + expect(kernel.map(values => values?.[folded])).toEqual(kernel.map(values => values === null ? undefined : foldOf(rebuilt[allCells.findIndex(([x, y]) => x === values[cell][0] && y === values[cell][1])]))); + held = Math.max(held, incremental.reduce((sum, bucket) => sum + bucket.length, 0)); + } + expect(held).toBeGreaterThan(400); + }); +}); + +const rank = Symbol("indexesTest/rank"); +const lane = Symbol("indexesTest/lane"); +const flag = Symbol("indexesTest/flag"); +const pair = Symbol("indexesTest/pair"); +declare module "@adobe/ecs" { + interface Components { + [rank]: { type: number }; + [lane]: { type: number }; + [flag]: { type: boolean }; + [pair]: { type: readonly [number, number] }; + } +} +Component.register(rank, { type: { type: "number" } }); +Component.register(lane, { type: { type: "integer", minimum: -100, maximum: 100 } }); +Component.register(flag, { type: { type: "boolean" } }); +Component.register(pair, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); + +const byRank = Index.create({ name: "indexesTest/byRank", key: [rank], sorted: true }); +const byLaneRank = Index.create({ name: "indexesTest/byLaneRank", key: [lane, rank], sorted: true, components: { has: [weight] } }); +const byName = Index.create({ name: "indexesTest/byName", key: [label], sorted: true, unique: true }); +const byFlagPair = Index.create({ name: "indexesTest/byFlagPair", key: [flag, pair], sorted: true }); + +/** What a sorted lookup gave: its rows' entities and ranks, and whether it changed. */ +let ranked: { readonly ranks: readonly number[]; readonly changed: boolean } = { ranks: [], changed: false }; +const ranksOf = (list: { readonly length: number; readonly changed: boolean; at(index: number): { readonly [rank]: number } }) => ({ + ranks: Array.from({ length: list.length }, (_, i) => list.at(i)[rank]), + changed: list.changed, +}); + +describe("sorted indexes on the main thread", () => { + const spawnRanks = System.create({ input: { type: "object", properties: { ranks: { type: "array", items: { type: "number" } } } } }, ({ input, commands }) => { + for (const value of input.ranks) commands.spawn([rank])({ [rank]: value }); + }); + const query = System.create({ + input: { type: "object", properties: { kind: { type: "string" }, value: { type: "number" }, other: { type: "number" } } }, + lookup: { ranks: { index: byRank, read: [rank] } }, + }, ({ input, lookup }) => { + const { value, other } = input; + switch (input.kind) { + case "all": ranked = ranksOf(lookup.ranks()); break; + case "eq": ranked = ranksOf(lookup.ranks(value)); break; + case ">=": ranked = ranksOf(lookup.ranks({ ">=": value })); break; + case ">": ranked = ranksOf(lookup.ranks({ ">": value })); break; + case "<": ranked = ranksOf(lookup.ranks({ "<": value })); break; + case "<=": ranked = ranksOf(lookup.ranks({ "<=": value })); break; + case "between": ranked = ranksOf(lookup.ranks({ ">=": value, "<": other })); break; + case "!=": ranked = ranksOf(lookup.ranks({ "!=": value })); break; + case "== and !=": ranked = ranksOf(lookup.ranks({ ">": value, "!=": other })); break; + default: { + // A comparison held, not a literal: the plugin passes it whole. + const held = { ">": value, "<=": other }; + ranked = ranksOf(lookup.ranks(held)); + } + } + }); + it("give rows in key order, numbers numerically with NaN last and -0 as +0, then by entity, for every comparison", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnRanks, { ranks: [3, -1, NaN, 2, -0, 0, 2, Infinity, -Infinity, 10] }); + await world.run(query, { kind: "all", value: 0, other: 0 }); + expect(ranked.ranks).toEqual([-Infinity, -1, -0, 0, 2, 2, 3, 10, Infinity, NaN]); + expect(ranked.changed).toBe(true); + const ask = async (kind: string, value: number, other = 0) => { + await world.run(query, { kind, value, other }); + return ranked.ranks; + }; + expect(await ask("eq", 2)).toEqual([2, 2]); + expect(await ask("eq", 0)).toEqual([-0, 0]); + expect(await ask("eq", 5)).toEqual([]); + expect(await ask(">=", 2)).toEqual([2, 2, 3, 10, Infinity, NaN]); + expect(await ask(">", 2)).toEqual([3, 10, Infinity, NaN]); + expect(await ask("<", 2)).toEqual([-Infinity, -1, -0, 0]); + expect(await ask("<=", 2)).toEqual([-Infinity, -1, -0, 0, 2, 2]); + expect(await ask("between", -1, 3)).toEqual([-1, -0, 0, 2, 2]); + expect(await ask("!=", 2)).toEqual([-Infinity, -1, -0, 0, 3, 10, Infinity, NaN]); + expect(await ask("== and !=", -1, 3)).toEqual([-0, 0, 2, 2, 10, Infinity, NaN]); + expect(await ask("held", 0, 3)).toEqual([2, 2, 3]); + const all = await Observe.toPromise(Observe.select(world, { required: [rank], order: { [rank]: true } })); + // Ties are in entity order. + let entities: readonly Entity[] = []; + await world.run(System.create({ lookup: { ranks: { index: byRank } } }, ({ lookup }) => { + const twos = lookup.ranks(2); + entities = [twos.at(0).entity, twos.at(1).entity]; + })); + expect(entities[0] < entities[1]).toBe(true); + void all; + }); + it("take a prefix of a compound key, its last part possibly a comparison", async () => { + await using world = createWorld({ [workers]: 0 }); + const model = Array.from({ length: 30 }, (_, k) => [(k % 3) - 1, 29 - k] as const); + await world.run(System.create(({ commands }) => { + model.forEach(([laneValue, rankValue], k) => commands.spawn([lane, rank, weight])({ [lane]: laneValue, [rank]: rankValue, [weight]: k })); + })); + let found: (readonly (readonly number[])[])[] = []; + await world.run(System.create({ lookup: { lanes: { index: byLaneRank, read: [lane, rank] } } }, ({ lookup }) => { + const read = (list: { readonly length: number; at(index: number): { readonly [lane]: number; readonly [rank]: number } }) => Array.from({ length: list.length }, (_, i) => [list.at(i)[lane], list.at(i)[rank]]); + found = [read(lookup.lanes(0)), read(lookup.lanes(1, { "<": 10 })), read(lookup.lanes({ "<": 0 })), read(lookup.lanes(-1, 26)), read(lookup.lanes(-1, 27))]; + })); + const sorted = [...model].sort((a, b) => a[0] - b[0] || a[1] - b[1]); + expect(found).toEqual([ + sorted.filter(([l]) => l === 0), + sorted.filter(([l, r]) => l === 1 && r < 10), + sorted.filter(([l]) => l < 0), + sorted.filter(([l, r]) => l === -1 && r === 26), + [], + ]); + }); + it("order strings by code point, booleans false first and tuples element by element", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create(({ commands }) => { + for (const name of ["b", "a", "~", "\u{1F600}", "ab", ""]) commands.spawn([label])({ [label]: name }); + for (const [on, x, y] of [[true, 1, 2], [false, 3, 0], [true, 1, 1], [false, -1, 5]] as const) commands.spawn([flag, pair])({ [flag]: on, [pair]: [x, y] }); + })); + let names: string[] = []; + let pairs: (readonly [boolean, readonly [number, number]])[] = []; + let one: string | null = null; + let none: string | null = "x"; + await world.run(System.create({ lookup: { names: { index: byName, read: [label] }, pairs: { index: byFlagPair, read: [flag, pair] } } }, ({ lookup }) => { + const all = lookup.names(); + names = Array.from({ length: all.length }, (_, i) => all.at(i)[label]); + const ordered = lookup.pairs(); + pairs = Array.from({ length: ordered.length }, (_, i) => [ordered.at(i)[flag], ordered.at(i)[pair]] as const); + one = lookup.names("ab")?.[label] ?? null; + none = lookup.names("zz")?.[label] ?? null; + })); + expect(names).toEqual(["", "a", "ab", "b", "~", "\u{1F600}"]); + expect(pairs).toEqual([[false, [-1, 5]], [false, [3, 0]], [true, [1, 1]], [true, [1, 2]]]); + expect(one).toBe("ab"); + expect(none).toBe(null); + }); + it("report changed for a range only when an entity entered, left or moved in it, or watched values changed, since the system last ran", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnRanks, { ranks: Array.from({ length: 1000 }, (_, k) => k) }); + await world.run(query, { kind: "between", value: 100, other: 110 }); + expect(ranked).toMatchObject({ changed: true }); + await world.run(query, { kind: "between", value: 100, other: 110 }); + expect(ranked).toMatchObject({ changed: false, ranks: [100, 101, 102, 103, 104, 105, 106, 107, 108, 109] }); + const entities = await Observe.toPromise(Observe.select(world, { required: [rank], order: { [rank]: true } })); + // A move far away isn't one here. + await world.run(System.create(({ commands }) => { commands.add(entities[900], rank, 950.5); })); + await world.run(query, { kind: "between", value: 100, other: 110 }); + expect(ranked.changed).toBe(false); + await world.run(System.create(({ commands }) => { commands.add(entities[900], rank, 105.5); })); + await world.run(query, { kind: "between", value: 100, other: 110 }); + expect(ranked).toMatchObject({ changed: true, ranks: [100, 101, 102, 103, 104, 105, 105.5, 106, 107, 108, 109] }); + // Emptied, it's changed once. + await world.run(System.create(({ commands }) => { for (const entity of entities) commands.despawn(entity); })); + await world.run(query, { kind: "between", value: 100, other: 110 }); + expect(ranked).toMatchObject({ changed: true, ranks: [] }); + await world.run(query, { kind: "between", value: 100, other: 110 }); + expect(ranked).toMatchObject({ changed: false, ranks: [] }); + }); + it("report a unique sorted index's conflicts, keeping the lower entity index", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create(({ commands }) => { + commands.spawn([label])({ [label]: "one" }); + commands.spawn([label])({ [label]: "two" }); + })); + const [first, second] = await Observe.toPromise(Observe.select(world, { required: [label] })); + let kept: Entity | null = null; + const find = System.create({ lookup: { names: { index: byName } } }, ({ lookup }) => { kept = lookup.names("one")?.entity ?? null; }); + expect((await runReported(world, find)).failures).toEqual([]); + await world.run(System.create(({ commands }) => { commands.add(second, label, "one"); })); + const report = await runReported(world, find); + expect(kept).toBe(first); + expect(report.failures).toEqual([{ kind: "index", name: "indexesTest/byName", message: `Index "indexesTest/byName" is unique, and entities ${first} and ${second} both have key "one"; lookups give ${first}, the one with the lower entity index.`, stack: null }]); + }); +}); + +let incrementalRanks: readonly number[] = []; +let incrementalLanes: readonly (readonly number[])[] = []; +const probeSorted = System.create({ name: "indexesTest/probeSorted", lookup: { lanes: { index: byLaneRank } } }, ({ lookup }) => { + const all = lookup.lanes(); + incrementalRanks = Array.from({ length: all.length }, (_, i) => all.at(i).entity); + incrementalLanes = [-1, 0, 1].map(laneValue => { + const list = lookup.lanes(laneValue, { ">=": 100, "<": 600 }); + return Array.from({ length: list.length }, (_, i) => list.at(i).entity); + }); +}); +const rerank = System.create({ + name: "indexesTest/rerank", input: { type: "object", properties: { share: { type: "number" }, seed: { type: "integer" } } }, + components: { read: [weight], write: [rank] }, before: [probeSorted], +}, (row, { input }) => { + if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[rank] = (row[weight] * 31 + input.seed * 17) % 1000; +}); + +/** A kernel folding the entities of its row's lane's range of ranks within 50 of its own, so kernels' sorted lookups are checked against the same index. */ +const foldLane = System.create({ + name: "indexesTest/foldLane", kernel: "cpu", + components: { read: [lane, rank], write: [folded] }, + lookup: { lanes: { index: byLaneRank } }, +}, (row, { lookup }) => { + const r = row[rank]; + const near = lookup.lanes(row[lane], { ">=": r - 50, "<": r + 50 }); + let sum = 0; + for (let i = 0; i < near.length; i++) sum += (near.at(i).entity % 997) * (i + 1); + row[folded] = sum; +}); + +describe("an incrementally refreshed sorted index", () => { + it.each([[true], [false]])("equals one built from scratch after random spawns, despawns, moves and key writes, its blocks split and dropped, as kernels read it too, WASM %s", async wasm => { + await using world = createWorld({ [workers]: 0 }, { wasm }); + const next = random(4242); + const pick = (list: readonly T[]): T | undefined => list[Math.floor(next() * list.length)]; + let held = 0; + for (let step = 0; step < 60; step++) { + const living = await Observe.toPromise(Observe.select(world, { required: [lane] })); + const operations = Math.floor(next() * 120); + const plan: (readonly [string, Entity | undefined, number])[] = []; + for (let k = 0; k < operations; k++) plan.push([pick(["spawn", "spawn", "spawn", "despawn", "despawn", "weigh", "unweigh", "set", "lane"]) ?? "spawn", pick(living), Math.floor(next() * 1000)]); + await world.run(System.create(({ commands }) => { + for (const [operation, entity, value] of plan) { + if (operation === "spawn") commands.spawn([lane, rank, weight, folded])({ [lane]: (value % 3) - 1, [rank]: value % 7 === 0 ? 500 : value, [weight]: value, [folded]: 0 }); + else if (entity === undefined) continue; + else if (operation === "despawn") commands.despawn(entity); + else if (operation === "weigh") commands.add(entity, weight, value); + else if (operation === "unweigh") commands.remove(entity, weight); + else if (operation === "set") commands.add(entity, rank, value); + else commands.add(entity, lane, (value % 3) - 1); + } + })); + await world.run(System.create({ systems: [rerank, probeSorted] }), { share: next() < 0.1 ? 0.9 : 0.05, seed: step }); + const scratch = Index.create({ key: [lane, rank], sorted: true, components: { has: [weight] } }); + let rebuiltRanks: readonly number[] = []; + let rebuiltLanes: readonly (readonly number[])[] = []; + await world.run(System.create({ lookup: { lanes: { index: scratch } } }, ({ lookup }) => { + const all = lookup.lanes(); + rebuiltRanks = Array.from({ length: all.length }, (_, i) => all.at(i).entity); + rebuiltLanes = [-1, 0, 1].map(laneValue => { + const list = lookup.lanes(laneValue, { ">=": 100, "<": 600 }); + return Array.from({ length: list.length }, (_, i) => list.at(i).entity); + }); + })); + expect(incrementalRanks).toEqual(rebuiltRanks); + expect(incrementalLanes).toEqual(rebuiltLanes); + // A kernel's ranges, each row's, against the same ranges from the index built from scratch. + await world.run(foldLane); + const rows = await Observe.toPromise(Observe.select(world, { required: [folded] })); + const kernel = await Promise.all(rows.map(entity => Observe.toPromise(Observe.entity(world, entity, { required: [lane, rank, folded] })))); + let expected: readonly number[] = []; + await world.run(System.create({ lookup: { lanes: { index: scratch } } }, ({ lookup }) => { + expected = kernel.map(values => { + if (values === null) return -1; + const list = lookup.lanes(values[lane], { ">=": values[rank] - 50, "<": values[rank] + 50 }); + let sum = 0; + for (let i = 0; i < list.length; i++) sum += (list.at(i).entity % 997) * (i + 1); + return sum; + }); + })); + expect(kernel.map(values => values?.[folded] ?? -1)).toEqual(expected); + held = Math.max(held, incrementalRanks.length); + // Once its blocks have split, every entity of a lane goes, so whole blocks empty and are dropped. + if (step === 40) await world.run(System.create({ components: { read: [lane] } }, (row, { commands }) => { if (row[lane] < 1) commands.despawn(row.entity); })); + } + expect(held).toBeGreaterThan(250); + }); +}); + +/** Whether two objects have the same own properties in the same order, each accessor's functions the same ones. */ +const sameAccessors = (a: object, b: object): boolean => { + const keysOf = (value: object): readonly (string | symbol)[] => Reflect.ownKeys(value); + const keys = keysOf(a); + if (keys.length !== keysOf(b).length) return false; + return keys.every((key, k) => { + const first = Object.getOwnPropertyDescriptor(a, key); + const second = Object.getOwnPropertyDescriptor(b, key); + return keysOf(b)[k] === key && first?.get === second?.get && first?.set === second?.set && (typeof first?.value !== "function" || first.value === second?.value); + }); +}; + +describe("lists and their rows", () => { + it("share their accessors across call sites and positions, so a body reading many sees one hidden class", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [0, 0], [1, 1]] }); + await world.run(System.create(({ commands }) => { + for (const value of [1, 2, 3]) commands.spawn([rank])({ [rank]: value }); + })); + let hashed: readonly object[] = []; + let sorted: readonly object[] = []; + await world.run(System.create({ lookup: { near: { index: grid, read: [weight] }, ranks: { index: byRank, read: [rank] } } }, ({ lookup }) => { + const first = lookup.near([0, 0]); + const second = lookup.near([1, 1]); + hashed = [first, second, first.at(0), first.at(1), second.at(0)]; + const all = lookup.ranks(); + const some = lookup.ranks({ ">": 1 }); + sorted = [all, some, all.at(0), all.at(1), some.at(0)]; + })); + expect(hashed).toHaveLength(5); + expect(sameAccessors(hashed[0], hashed[1])).toBe(true); + expect(sameAccessors(hashed[2], hashed[3]) && sameAccessors(hashed[2], hashed[4])).toBe(true); + expect(sameAccessors(sorted[0], sorted[1])).toBe(true); + expect(sameAccessors(sorted[2], sorted[3]) && sameAccessors(sorted[2], sorted[4])).toBe(true); + }); +}); + +describe("a lookup's rows across sync points", () => { + it("read the arrays their archetype grew into, after a sync point replaced them", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawnCells, { cells: [[0, 0], [0, 0]] }); + let weights: number[] = []; + const read = System.create({ lookup: { near: { index: grid, read: [weight] } } }, ({ lookup }) => { + const near = lookup.near([0, 0]); + weights = Array.from({ length: near.length }, (_, i) => near.at(i)[weight]); + }); + await world.run(read); + expect(weights).toEqual([0, 1]); + // Enough spawns into the same archetype to grow its arrays, and new weights for the two already read. + await world.run(spawnCells, { cells: Array.from({ length: 200 }, () => [5, 5]) }); + await world.run(System.create({ components: { write: [weight], read: [cell] } }, row => { + if (row[cell][0] === 0) row[weight] += 10; + })); + await world.run(read); + expect(weights).toEqual([10, 11]); + }); +}); + +describe("a long list's rows", () => { + it("are each its own while the list is valid, at its start, its middle and its end", async () => { + await using world = createWorld({ [workers]: 0 }); + const count = 1300; + await world.run(System.create(({ commands }) => { + for (let i = 0; i < count; i++) commands.spawn([rank])({ [rank]: (i * 7919) % count }); + })); + let fromStart: number[] = []; + let fromEnd: number[] = []; + await world.run(System.create({ lookup: { ranks: { index: byRank, read: [rank] } } }, ({ lookup }) => { + const all = lookup.ranks(); + // Every row held at once, then read. + const held = Array.from({ length: all.length }, (_, i) => all.at(i)); + fromStart = held.map(row => row[rank]); + const reversed = Array.from({ length: all.length }, (_, i) => all.at(all.length - 1 - i)); + fromEnd = reversed.map(row => row[rank]); + })); + const expected = Array.from({ length: count }, (_, i) => i); + expect(fromStart).toEqual(expected); + expect(fromEnd).toEqual([...expected].reverse()); + }); +}); diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts new file mode 100644 index 000000000..2fc6cc5a1 --- /dev/null +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -0,0 +1,1209 @@ +import type { IndexDefinition } from '../../interface/registry/index.js'; +import { indexDefinitionOf, indexOfLookup, labelOfItem, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import type { Failure } from '../../interface/report/failure.js'; +import type { SystemLookup } from '../../interface/system/access.js'; +import type { Archetype, Storage } from './archetype.js'; +import { readCell } from './cells.js'; +import { chunkShift } from './kernel.js'; +import { journalEnd } from './journal.js'; +import { encodeStored, hashWords, keyPartsOf, missing, release as releaseInterned, retain, type KeyPart } from './index-keys.js'; +import { + bucketFor, coverBuckets, coverEntities, createHashTable, entryWords, insertEntry, lengthOf, loadHashTable, mergeEntries, place, placeAll, recordHash, + recordKey, rehash, relocate, slotsHold, releaseHashTable, removeEntry, startOf, tidyHashTable, unwatchHashComponent, watchHashComponent, type HashTable, +} from './index-hash.js'; +import { allocateArray, freeArray } from './arrays.js'; +import { buildKernel, buildViews, maximumPartitions, phases, pieceEntities, planWords, runBuildStep, scalarCodes, writePlan, type BuildViews, type HashBuild, type Phase } from './index-build.js'; +import type { Pool } from './pool.js'; +import { idOf, observe, startTicking, type State } from './state.js'; +import { + blockBase, blockEntries, coverSortedEntities, createSortedTable, entryOf, insertSorted, loadSortedTable, refreshWatchedTree, relocateSorted, + releaseSortedTable, removeSorted, sameStoredKey, stampWatched, unwatchSortedComponent, watchSortedComponent, type SortedTable, +} from './index-sorted.js'; +import { compareObjects, keptSorted } from './index-keys.js'; + +/** + * What a world keeps of an index while some registered system or finalizer of it uses one: its structure, which + * archetypes it holds the entities of, how far it read the journal and the ticks of its key, and, by component its + * lookups watch, how far it read that component's ticks. + */ +export type WorldIndex = { + readonly definition: IndexDefinition; + readonly label: string; + /** Runners and finalizers using it: it's released when the last one is. */ + users: number; + readonly keys: readonly number[]; + readonly has: readonly number[]; + readonly not: readonly number[]; + readonly parts: readonly KeyPart[]; + /** Whether a part holds objects, whose values the index interns. */ + readonly objects: boolean; + readonly words: number; + readonly unique: boolean; + /** By archetype id: 1 when it holds that archetype's entities, 2 when not, 0 until checked; and those it holds. */ + member: Int8Array; + readonly members: Archetype[]; + /** The journal position it read up to, or -1 before it's built; the tick it was last refreshed at. */ + position: number; + refreshed: number; + /** + * The tick of the last refresh at which an entity entered or left it or moved to another key or position in it, or + * it was built; 0 before. + */ + changed: number; + /** By watched component: how many lookups watch it, and the tick its ticks were read up to. */ + readonly watchers: Map; + readonly scanned: Map; + /** Its structure: a hash table, or for a sorted index its blocks in key order. */ + readonly hash: HashTable | null; + readonly sorted: SortedTable | null; + /** Scratch: a key's words, by entity index the pass that last saw it, and the entity indexes a refresh goes over. */ + readonly key: Uint32Array; + stamps: Int32Array; + pass: number; + pending: Int32Array; + pendingArchetypes: Int32Array; + pendingRows: Int32Array; + /** What an incremental refresh changes, by bucket: removals' entity indexes and insertions' entries. */ + operations: Int32Array; + /** A unique index's insertions of a refresh, as bucket and entity index pairs. */ + inserted: Int32Array; + /** + * What a rebuild lists, kept for the next: entries, keys' words and hashes, and buckets, by entry; for a sorted + * index, the entries' order and a spare for sorting it, and a digit histogram. + */ + readonly built: { entries: Int32Array; keys: Uint32Array; hashes: Uint32Array; buckets: Int32Array; order: Int32Array; spare: Int32Array; readonly histogram: Int32Array }; + /** What a build across the pool lists, in the arena, kept for the next; null before the first. */ + pooled: Pooled | null; + /** A merge's scratch, kept for the next: by bucket, its operations' start; operations in bucket order; removals, insertions and a segment. */ + merging: { starts: Int32Array; order: Int32Array; removed: Int32Array; added: Int32Array; segment: Int32Array }; +}; + +/** A build across the pool's arrays in the arena (`index-build.ts`): by entry, by piece, by partition, and its plan. */ +type Pooled = { + readonly grouped: Int32Array; + readonly groupedKeys: Uint32Array; + readonly groupedHashes: Uint32Array; + readonly local: Int32Array; + readonly first: Int32Array; + readonly lengths: Int32Array; + readonly overflow: Int32Array; + readonly histogram: Int32Array; + /** By partition: starts, `maximumPartitions + 1` of them; buckets, capacity, crowded buckets, bucket and capacity bases, overflowed. */ + readonly partitions: Int32Array; + plan: Float64Array; +}; + +/** Below this many changes, and up to an eighth of what an index holds, a refresh patches it rather than building it again. */ +const rebuildMinimum = 4096; + +/** The index a world keeps for a declaration, created and counted as one more user; nothing is built until it's refreshed. */ +export const acquireIndex = (state: State, definition: IndexDefinition): WorldIndex => { + const existing = state.indexes.get(definition); + if (existing !== undefined) { + existing.users++; + return existing; + } + const { key, components, unique } = definition.declaration; + const keys = key.map(part => idOf(state, part)); + const { parts, words } = keyPartsOf(keys.map(id => state.layouts[id])); + const sorted = keptSorted(definition.declaration); + const index: WorldIndex = { + definition, + label: labelOfItem(definition), + users: 1, + keys, + has: (components?.has ?? []).map(component => idOf(state, component)), + not: (components?.not ?? []).map(component => idOf(state, component)), + parts, + objects: parts.some(part => part.kind === "object"), + words, + unique: unique === true, + member: new Int8Array(16), + members: [], + position: -1, + refreshed: 0, + changed: 0, + watchers: new Map(), + scanned: new Map(), + hash: sorted ? null : createHashTable(state.memory, words), + sorted: sorted ? createSortedTable(state.memory, words, parts) : null, + key: new Uint32Array(Math.max(1, words)), + stamps: new Int32Array(0), + pass: 0, + pending: new Int32Array(64), + pendingArchetypes: new Int32Array(64), + pendingRows: new Int32Array(64), + operations: new Int32Array(64), + inserted: new Int32Array(64), + built: { entries: new Int32Array(0), keys: new Uint32Array(0), hashes: new Uint32Array(0), buckets: new Int32Array(0), order: new Int32Array(0), spare: new Int32Array(0), histogram: new Int32Array(sorted && words > 0 ? 65536 : 0) }, + pooled: null, + merging: { starts: new Int32Array(0), order: new Int32Array(0), removed: new Int32Array(0), added: new Int32Array(0), segment: new Int32Array(0) }, + }; + state.indexes.set(definition, index); + state.journal.readers++; + return index; +}; + +/** One fewer user; the last one releases what the index holds, its arena blocks included. */ +export const releaseIndex = (state: State, index: WorldIndex): void => { + if (--index.users > 0) return; + state.indexes.delete(index.definition); + state.journal.readers--; + if (index.hash !== null) releaseHashTable(index.hash); + if (index.sorted !== null) releaseSortedTable(index.sorted); + if (index.pooled !== null) freePooled(state, index.pooled); + index.pooled = null; +}; + +/** Entries an index holds. */ +const sizeOf = (index: WorldIndex): number => index.hash?.size ?? index.sorted?.size ?? 0; + +/** Counts one more lookup watching a component, whose changes then stamp the buckets holding its rows. */ +export const watchIndex = (state: State, index: WorldIndex, component: number): void => { + const count = index.watchers.get(component) ?? 0; + index.watchers.set(component, count + 1); + if (count > 0) return; + if (index.hash !== null) watchHashComponent(index.hash, component, state.tick); + if (index.sorted !== null) watchSortedComponent(index.sorted, component, state.tick); + index.scanned.set(component, state.tick); +}; + +/** Counts one fewer lookup watching a component. */ +export const unwatchIndex = (index: WorldIndex, component: number): void => { + const count = (index.watchers.get(component) ?? 0) - 1; + if (count > 0) { + index.watchers.set(component, count); + return; + } + index.watchers.delete(component); + index.scanned.delete(component); + if (index.hash !== null) unwatchHashComponent(index.hash, component); + if (index.sorted !== null) unwatchSortedComponent(index.sorted, component); +}; + +/** Notes the archetypes created since it last looked that it holds the entities of. */ +const refreshMembers = (state: State, index: WorldIndex): void => { + const { archetypes } = state; + if (index.member.length < archetypes.length) { + const grown = new Int8Array(Math.max(archetypes.length, 2 * index.member.length)); + grown.set(index.member); + index.member = grown; + } + const { member } = index; + for (let id = 0; id < archetypes.length; id++) { + if (member[id] !== 0) continue; + const archetype = archetypes[id]; + const { storageById } = archetype; + let holds = !archetype.resource && !(archetype.meta && index.keys.length === 0 && index.has.length === 0); + for (let k = 0; holds && k < index.keys.length; k++) holds = storageById[index.keys[k]] !== undefined; + for (let k = 0; holds && k < index.has.length; k++) holds = storageById[index.has[k]] !== undefined; + for (let k = 0; holds && k < index.not.length; k++) holds = storageById[index.not[k]] === undefined; + member[id] = holds ? 1 : 2; + if (holds) index.members.push(archetype); + } +}; + +/** Writes a row's key into `out` from `at`, interning its object parts' values when `intern` is set. */ +const encodeRow = (index: WorldIndex, archetype: Archetype, row: number, out: Uint32Array, at: number, intern: boolean): void => { + const { parts, keys } = index; + for (let p = 0; p < parts.length; p++) { + const storage = archetype.storageById[keys[p]]; + if (storage !== undefined) encodeStored(parts[p], storage, row, out, at + parts[p].first, intern); + } +}; + +/** Whether any object part's value in `key` is one nothing holds yet. */ +const unknownValue = (index: WorldIndex, key: Uint32Array): boolean => { + for (const part of index.parts) if (part.kind === "object" && key[part.first] === missing) return true; + return false; +}; + +/** Each object part's value in a key's words from `at`, interned once more (`retain`) or once less (`release`). */ +const holdWords = (index: WorldIndex, words: Uint32Array, at: number, hold: boolean): void => { + for (const part of index.parts) { + if (part.kind !== "object") continue; + const id = words[at + part.first]; + if (hold) retain(part, id); + else releaseInterned(part, id); + } +}; + +/** Each object part's value in bucket `bucket`'s key, interned once more or once less. */ +const holdKey = (hash: HashTable, index: WorldIndex, bucket: number, hold: boolean): void => { + if (index.objects) holdWords(index, hash.records, bucket * hash.stride + recordKey, hold); +}; + +/** A key's values, for a failure's message: those of a row holding it. */ +const keyText = (state: State, index: WorldIndex, archetype: Archetype, row: number): string => { + const values = index.keys.map(id => { + const storage = archetype.storageById[id]; + return storage === undefined ? undefined : readCell(storage, row); + }); + const text = (value: unknown): string => { + try { + return typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value) ?? String(value); + } + catch { + return String(value); + } + }; + return values.length === 1 ? text(values[0]) : `[${values.map(text).join(", ")}]`; +}; + +/** The entity an entry names, read from its archetype's row. */ +const entityOf = (state: State, entries: Int32Array, at: number): number => state.archetypes[entries[at + 1]].entities[entries[at + 2]]; + +/** + * A unique index's failure: entity index `inserted` was filed in a bucket holding another entity. The bucket's first + * entry, the lower entity index, is the one lookups give. + */ +const conflict = (state: State, index: WorldIndex, hash: HashTable, bucket: number, inserted: number, failures: Failure[]): void => { + const { entries } = hash; + const first = startOf(hash, bucket) * entryWords; + const kept = entityOf(state, entries, first); + const other = entries[first] === inserted ? entityOf(state, entries, first + entryWords) : liveEntity(state, inserted); + reportConflict(state, index, kept, other, state.archetypes[entries[first + 1]], entries[first + 2], failures); +}; + +/** The entity a live entity index is. */ +const liveEntity = (state: State, index: number): number => state.archetypes[state.entities.archetype[index]].entities[state.entities.row[index]]; + +/** A unique index's failure: entities `kept`, the lower entity index, and `other` share the key the row given holds. */ +const reportConflict = (state: State, index: WorldIndex, kept: number, other: number, archetype: Archetype, row: number, failures: Failure[]): void => { + failures.push({ + kind: "index", + name: index.label, + message: `Index "${index.label}" is unique, and entities ${kept} and ${other} both have key ${keyText(state, index, archetype, row)}; lookups give ${kept}, the one with the lower entity index.`, + stack: null, + }); +}; + +/** + * A sorted unique index's failure: entity index `inserted`'s entry is beside another of its key, the first of whose + * entries, the lower entity index, lookups give. + */ +const sortedConflict = (state: State, index: WorldIndex, sorted: SortedTable, inserted: number, failures: Failure[]): void => { + const base = entryOf(sorted, inserted); + if (base < 0) return; + const { entries, words } = sorted; + // The first entry of its key, walking back across blocks. + let first = base; + const before = previousEntry(sorted, base); + const after = nextEntry(sorted, base); + const sameAt = (other: number): boolean => other >= 0 && sameStoredKey(sorted, other, entries, base); + if (!sameAt(before) && !sameAt(after)) return; + for (let previous = before; sameAt(previous); previous = previousEntry(sorted, previous)) first = previous; + const other = first === base ? (sameAt(after) ? after : before) : base; + const kept = state.archetypes[entries[first + words + 1]].entities[entries[first + words + 2]]; + const second = state.archetypes[entries[other + words + 1]].entities[entries[other + words + 2]]; + reportConflict(state, index, kept, second, state.archetypes[entries[first + words + 1]], entries[first + words + 2], failures); +}; + +/** The entry before or after the one at `base`, across blocks, or -1. */ +const previousEntry = (sorted: SortedTable, base: number): number => { + const block = Math.floor(base / (blockEntries * sorted.stride)); + const offset = (base - blockBase(sorted, block)) / sorted.stride; + if (offset > 0) return base - sorted.stride; + const k = sorted.positions[block]; + if (k === 0) return -1; + const previous = sorted.order[k - 1]; + return blockBase(sorted, previous) + (sorted.lengths[previous] - 1) * sorted.stride; +}; +const nextEntry = (sorted: SortedTable, base: number): number => { + const block = Math.floor(base / (blockEntries * sorted.stride)); + const offset = (base - blockBase(sorted, block)) / sorted.stride; + if (offset + 1 < sorted.lengths[block]) return base + sorted.stride; + const k = sorted.positions[block]; + if (k + 1 >= sorted.blocks) return -1; + return blockBase(sorted, sorted.order[k + 1]); +}; + +/** Grows a scratch array of entity indexes to at least `length`, keeping its contents. */ +const fit = (array: Int32Array, length: number): Int32Array => { + if (array.length >= length) return array; + const grown = new Int32Array(Math.max(length, 2 * array.length)); + grown.set(array); + return grown; +}; + +/** + * Adds an entity index to a refresh's pending ones, with the archetype id and row a scan found it at, or -1 for one the + * journal lists, which `patch` locates. + */ +const list = (index: WorldIndex, entity: number, archetype: number, row: number, count: number): number => { + if (count === index.pending.length) { + index.pending = fit(index.pending, count + 1); + index.pendingArchetypes = fit(index.pendingArchetypes, count + 1); + index.pendingRows = fit(index.pendingRows, count + 1); + } + index.pending[count] = entity; + index.pendingArchetypes[count] = archetype; + index.pendingRows[count] = row; + return count + 1; +}; + +/** `list`, once per entity index. */ +const pend = (index: WorldIndex, entity: number, archetype: number, row: number, count: number): number => { + if (index.stamps[entity] === index.pass) return count; + index.stamps[entity] = index.pass; + return list(index, entity, archetype, row, count); +}; + +/** Calls `each` with every row of a storage whose `changed` tick is after `since`, a whole column a GPU kernel wrote included. */ +const changedRows = (archetype: Archetype, storage: Storage, since: number, each: (row: number) => void): void => { + const whole = storage.columnChanged > since; + const { changed, chunkChanged } = storage; + for (let chunk = 0, count = Math.ceil(archetype.count / 2 ** chunkShift); chunk < count; chunk++) { + if (!whole && !(chunkChanged[chunk] > since)) continue; + for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { + if (whole || changed[row] > since) each(row); + } + } +}; + +/** Whether a component may have been written since `since`: by a system, or by a command's value at a sync point. */ +const writtenSince = (state: State, component: number, since: number): boolean => state.written[component] > since || state.valuesWritten > since; + +/** Refreshes the indexes of a tier, or of the finalizers, before they run, their conflicts going in the run's report. */ +export const refreshUses = (state: State, uses: readonly IndexUse[], pool: Pool | null = null): Promise | null => { + for (let k = 0; k < uses.length; k++) { + const pending = refreshIndex(state, uses[k].index, uses[k].watching, state.indexFailures, pool); + if (pending !== null) return finishUses(state, uses, k + 1, pending, pool); + } + return null; +}; + +/** The rest of `refreshUses` once a refresh building across the pool is done. */ +const finishUses = async (state: State, uses: readonly IndexUse[], from: number, pending: Promise, pool: Pool | null): Promise => { + await pending; + for (let k = from; k < uses.length; k++) { + const next = refreshIndex(state, uses[k].index, uses[k].watching, state.indexFailures, pool); + if (next !== null) await next; + } +}; + +/** Each index the runners use once, with every component their lookups there watch. */ +export const mergeUses = (runners: readonly { readonly indexes: readonly IndexUse[] }[]): readonly IndexUse[] => { + let merged: Map> | null = null; + for (const runner of runners) { + for (const use of runner.indexes) { + merged ??= new Map(); + let watching = merged.get(use.index); + if (watching === undefined) merged.set(use.index, watching = new Set()); + for (const component of use.watching) watching.add(component); + } + } + return merged === null ? noUses : [...merged].map(([index, watching]) => ({ index, watching: [...watching] })); +}; + +/** + * Brings an index up to date before a tier of systems that use it, or the finalize phase: rebuilt the first time, and + * when the journal dropped entries it hadn't read or more than an eighth of it would change; else patched for the + * entities the journal lists and the rows whose key parts changed since its last refresh. Each component in `watching`, + * which a lookup of the tier watches, stamps the buckets of its rows that changed. Conflicts of a unique index go in + * `failures`. + */ +export const refreshIndex = (state: State, index: WorldIndex, watching: readonly number[], failures: Failure[], pool: Pool | null = null): Promise | null => { + const { journal } = state; + const end = journalEnd(journal); + const since = index.refreshed; + const tick = state.tick; + let keyed = false; + for (let k = 0; k < index.keys.length && !keyed; k++) keyed = writtenSince(state, index.keys[k], since); + let pending: Promise | null = null; + if (index.position < 0 || index.position < journal.first) pending = rebuildAny(state, index, tick, failures, pool); + else if (index.position !== end || keyed) pending = refresh(state, index, end, keyed, tick, failures, pool); + if (pending !== null) return finishRefresh(state, index, end, tick, watching, pending); + refreshed(state, index, end, tick, watching); + return null; +}; + +/** Notes a refresh up to journal position `end` at `tick`, and stamps the buckets of watched components' changes. */ +const refreshed = (state: State, index: WorldIndex, end: number, tick: number, watching: readonly number[]): void => { + index.position = end; + index.refreshed = tick; + for (let k = 0; k < watching.length; k++) scanWatched(state, index, watching[k], tick); +}; + +const finishRefresh = async (state: State, index: WorldIndex, end: number, tick: number, watching: readonly number[], pending: Promise): Promise => { + await pending; + refreshed(state, index, end, tick, watching); +}; + +/** Builds an index again: in phases, across the pool when there is one, for a large hash index keyed by structs; else here. */ +const rebuildAny = (state: State, index: WorldIndex, tick: number, failures: Failure[], pool: Pool | null): Promise | null => { + index.changed = tick; + const phased = rebuildPhased(state, index, tick, failures, pool); + if (phased !== false) return phased; + rebuild(state, index, tick, failures); + return null; +}; + +/** Patches an index for the entities the journal lists from its position to `end` and, when `keyed`, those whose key changed. */ +const refresh = (state: State, index: WorldIndex, end: number, keyed: boolean, tick: number, failures: Failure[], pool: Pool | null): Promise | null => { + refreshMembers(state, index); + const { journal, entities } = state; + // Rows a scan of one key part finds are each listed once; the journal's entries, and several parts, need marks. + const marking = index.position !== end || index.keys.length > 1; + if (marking) { + if (index.stamps.length < entities.length) { + const grown = new Int32Array(Math.max(entities.length, 2 * index.stamps.length)); + grown.set(index.stamps); + index.stamps = grown; + } + index.pass++; + } + // Past what a patch takes it's built again: listing stops once the rows found pass it. + const most = Math.max(rebuildMinimum, sizeOf(index) / 8); + if (end - index.position > most) return rebuildAny(state, index, tick, failures, pool); + let count = 0; + for (let at = index.position; at < end; at++) count = pend(index, journal.indexes[at - journal.first], -1, 0, count); + if (keyed) { + const since = index.refreshed; + for (const archetype of index.members) { + const rows = archetype.entities; + const id = archetype.id; + for (const part of index.keys) { + const storage = archetype.storageById[part]; + if (storage === undefined || !storage.ticked || !writtenSince(state, part, since)) continue; + const whole = storage.columnChanged > since; + const { changed, chunkChanged } = storage; + for (let chunk = 0, chunks = Math.ceil(archetype.count / 2 ** chunkShift); chunk < chunks; chunk++) { + if (!whole && !(chunkChanged[chunk] > since)) continue; + for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { + if (!whole && !(changed[row] > since)) continue; + count = marking ? pend(index, rows[row] >>> 0, id, row, count) : list(index, rows[row] >>> 0, id, row, count); + if (count > most) return rebuildAny(state, index, tick, failures, pool); + } + } + } + } + } + if (count === 0) return null; + if (count > most) return rebuildAny(state, index, tick, failures, pool); + if (index.hash !== null) patch(state, index, index.hash, count, tick, failures); + else if (index.sorted !== null) patchSorted(state, index, index.sorted, count, tick, failures); + return null; +}; + +/** + * A bucket at least this long takes a refresh's operations in one merge, rather than a shift each; shorter ones take + * each as it comes. + */ +const mergeMinimum = 64; + +/** + * Files each pending entity in the bucket of its key, or none: an operation applies at once on a short bucket, and on + * a long one waits to merge with the bucket's others. A unique index's buckets holding two entries once every + * operation applied are conflicts. + */ +const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, tick: number, failures: Failure[]): void => { + const { pending, key, words, pendingArchetypes, pendingRows } = index; + const { entities } = state; + coverEntities(hash, entities.length); + // Each waiting operation: bucket, entity index, archetype id or -1 for a removal, row. + let waiting = 0; + const wait = (bucket: number, entity: number, archetype: number, row: number): void => { + if (4 * (waiting + 1) > index.operations.length) index.operations = fit(index.operations, 4 * (waiting + 1)); + const at = 4 * waiting++; + index.operations[at] = bucket; + index.operations[at + 1] = entity; + index.operations[at + 2] = archetype; + index.operations[at + 3] = row; + }; + // A unique index's insertions, as bucket and entity index pairs, checked once all apply. + let inserted = 0; + const remove = (bucket: number, entity: number): void => { + index.changed = tick; + holdKey(hash, index, bucket, false); + if (lengthOf(hash, bucket) >= mergeMinimum) wait(bucket, entity, -1, 0); + else removeEntry(hash, bucket, entity, tick); + }; + for (let k = 0; k < count; k++) { + const entity = pending[k]; + const scanned = pendingArchetypes[k]; + const archetypeId = scanned >= 0 ? scanned : entity < entities.length ? entities.archetype[entity] : -1; + const old = hash.bucketOf[entity]; + if (archetypeId < 0 || index.member[archetypeId] !== 1) { + if (old < 0) continue; + remove(old, entity); + hash.bucketOf[entity] = -1; + continue; + } + const archetype = state.archetypes[archetypeId]; + const row = scanned >= 0 ? pendingRows[k] : entities.row[entity]; + encodeRow(index, archetype, row, key, 0, false); + // A value nothing held is interned now, once for this entry, with the rest of its key. + const fresh = index.objects && unknownValue(index, key); + if (fresh) encodeRow(index, archetype, row, key, 0, true); + const bucket = bucketFor(hash, key, 0, hashWords(key, 0, words), tick); + if (bucket === old) { + relocate(hash, bucket, entity, archetypeId, row); + continue; + } + if (!fresh) holdKey(hash, index, bucket, true); + index.changed = tick; + if (old >= 0) remove(old, entity); + if (lengthOf(hash, bucket) >= mergeMinimum) wait(bucket, entity, archetypeId, row); + else insertEntry(hash, bucket, entity, archetypeId, row, tick); + hash.bucketOf[entity] = bucket; + if (index.unique) { + if (2 * (inserted + 1) > index.inserted.length) index.inserted = fit(index.inserted, 2 * (inserted + 1)); + index.inserted[2 * inserted] = bucket; + index.inserted[2 * inserted++ + 1] = entity; + } + } + if (waiting > 0) merge(index, hash, waiting, tick); + for (let k = 0; k < inserted; k++) { + const bucket = index.inserted[2 * k]; + if (lengthOf(hash, bucket) > 1 && hash.bucketOf[index.inserted[2 * k + 1]] === bucket) conflict(state, index, hash, bucket, index.inserted[2 * k + 1], failures); + } + tidyHashTable(hash, tick); +}; + +/** The object parts' values of a key's words from `at`, for comparing a sorted index's entries with them. */ +const valuesOfKey = (index: WorldIndex, key: Uint32Array, at: number, values: unknown[]): readonly unknown[] => { + for (let p = 0; p < index.parts.length; p++) { + const part = index.parts[p]; + values[p] = part.kind === "object" ? part.interned.values[key[at + part.first]] : undefined; + } + return values; +}; + +/** Moves each pending entity's entry of a sorted index to its key's place, or removes it. */ +const patchSorted = (state: State, index: WorldIndex, sorted: SortedTable, count: number, tick: number, failures: Failure[]): void => { + const { pending, key, pendingArchetypes, pendingRows } = index; + const { entities } = state; + coverSortedEntities(sorted, entities.length); + const objects = index.parts.some(part => part.kind === "object"); + const values: unknown[] = []; + let inserted = 0; + for (let k = 0; k < count; k++) { + const entity = pending[k]; + const scanned = pendingArchetypes[k]; + const archetypeId = scanned >= 0 ? scanned : entity < entities.length ? entities.archetype[entity] : -1; + const old = entryOf(sorted, entity); + if (archetypeId < 0 || index.member[archetypeId] !== 1) { + if (old < 0) continue; + if (objects) holdWords(index, sorted.entries, old, false); + removeSorted(sorted, entity, tick); + index.changed = tick; + continue; + } + const archetype = state.archetypes[archetypeId]; + const row = scanned >= 0 ? pendingRows[k] : entities.row[entity]; + encodeRow(index, archetype, row, key, 0, false); + const fresh = index.objects && unknownValue(index, key); + if (fresh) encodeRow(index, archetype, row, key, 0, true); + if (old >= 0 && !fresh && sameStoredKey(sorted, old, key, 0)) { + relocateSorted(sorted, entity, archetypeId, row); + continue; + } + if (objects && !fresh) holdWords(index, key, 0, true); + if (old >= 0) { + if (objects) holdWords(index, sorted.entries, old, false); + removeSorted(sorted, entity, tick); + } + insertSorted(sorted, key, 0, objects ? valuesOfKey(index, key, 0, values) : values, entity, archetypeId, row, tick); + index.changed = tick; + if (index.unique) { + if (inserted + 1 > index.inserted.length) index.inserted = fit(index.inserted, inserted + 1); + index.inserted[inserted++] = entity; + } + } + for (let k = 0; k < inserted; k++) sortedConflict(state, index, sorted, index.inserted[k], failures); +}; + +/** Applies the operations long buckets wait with, each bucket's in one merge, in entity order. */ +const merge = (index: WorldIndex, hash: HashTable, count: number, tick: number): void => { + const operations = index.operations; + const merging = index.merging; + // By bucket, counted then placed, in the order they came; then each bucket's by entity index. + if (merging.starts.length < hash.count + 1) merging.starts = new Int32Array(Math.max(hash.count + 1, 2 * merging.starts.length)); + if (merging.order.length < count) { + const length = Math.max(count, 2 * merging.order.length); + merging.order = new Int32Array(length); + merging.removed = new Int32Array(length); + merging.added = new Int32Array(length * entryWords); + } + const { starts, order, removed, added } = merging; + starts.fill(0, 0, hash.count + 1); + for (let k = 0; k < count; k++) starts[operations[4 * k] + 1]++; + for (let bucket = 0; bucket < hash.count; bucket++) starts[bucket + 1] += starts[bucket]; + for (let k = 0; k < count; k++) order[starts[operations[4 * k]]++] = k; + let scratch = merging.segment; + for (let start = 0; start < count;) { + const bucket = operations[4 * order[start]]; + let end = start + 1; + while (end < count && operations[4 * order[end]] === bucket) end++; + // Insertion sort by entity index: a bucket's operations of one refresh are few. + for (let k = start + 1; k < end; k++) { + const moved = order[k]; + const entity = operations[4 * moved + 1]; + let at = k; + while (at > start && operations[4 * order[at - 1] + 1] > entity) { + order[at] = order[at - 1]; + at--; + } + order[at] = moved; + } + let removals = 0; + let insertions = 0; + for (let k = start; k < end; k++) { + const at = 4 * order[k]; + if (operations[at + 2] < 0) removed[removals++] = operations[at + 1]; + else { + added[insertions * entryWords] = operations[at + 1]; + added[insertions * entryWords + 1] = operations[at + 2]; + added[insertions * entryWords + 2] = operations[at + 3]; + insertions++; + } + } + const length = lengthOf(hash, bucket) * entryWords; + if (scratch.length < length) scratch = new Int32Array(Math.max(length, 2 * scratch.length)); + mergeEntries(hash, bucket, removed, removals, added, insertions, scratch, tick); + start = end; + } + merging.segment = scratch; +}; + +/** Builds an index from every entity it holds, in entity order. */ +const rebuild = (state: State, index: WorldIndex, tick: number, failures: Failure[]): void => { + refreshMembers(state, index); + const { entities } = state; + const { member, words } = index; + // Interned values start again, each counted by the entries that hold it. + for (const part of index.parts) { + if (part.kind !== "object") continue; + part.interned.ids.clear(); + part.interned.values.length = 0; + part.interned.counts.length = 0; + part.interned.free.length = 0; + } + let total = 0; + for (const archetype of index.members) total += archetype.count; + // Kept for the next rebuild. + const built = index.built; + if (built.hashes.length < total) { + const length = Math.max(total, 2 * built.hashes.length); + built.entries = new Int32Array(length * entryWords); + built.keys = new Uint32Array(Math.max(1, length * words)); + built.hashes = new Uint32Array(length); + built.buckets = new Int32Array(length); + } + const { entries: listed, keys, hashes } = built; + const hashing = index.hash !== null; + let count = 0; + const add = (entity: number): void => { + const archetypeId = entities.archetype[entity]; + const archetype = state.archetypes[archetypeId]; + const row = entities.row[entity]; + listed[count * entryWords] = entity; + listed[count * entryWords + 1] = archetypeId; + listed[count * entryWords + 2] = row; + encodeRow(index, archetype, row, keys, count * words, true); + if (hashing) hashes[count] = hashWords(keys, count * words, words); + count++; + }; + // Few entities among many: theirs sorted; else every entity index in turn. + if (total * Math.log2(total + 2) < entities.length) { + const found = new Int32Array(total); + let at = 0; + for (const archetype of index.members) for (let row = 0; row < archetype.count; row++) found[at++] = archetype.entities[row] >>> 0; + found.sort(); + for (let k = 0; k < at; k++) add(found[k]); + } + else { + for (let entity = 1; entity < entities.length; entity++) { + const archetypeId = entities.archetype[entity]; + if (archetypeId >= 0 && member[archetypeId] === 1) add(entity); + } + } + for (const component of index.scanned.keys()) index.scanned.set(component, tick); + const { hash, sorted } = index; + if (sorted !== null) { + const order = sortedOrder(index, count, keys); + loadSortedTable(sorted, count, keys, listed, order, tick, entities.length); + if (!index.unique) return; + for (let k = 1; k < count; k++) { + const previous = order[k - 1]; + const entry = order[k]; + let same = true; + for (let w = 0; w < words && same; w++) same = keys[previous * words + w] === keys[entry * words + w]; + if (!same) continue; + let first = k - 1; + while (first > 0 && keyEquals(keys, words, order[first - 1], order[first])) first--; + const kept = order[first]; + reportConflict(state, index, state.archetypes[listed[3 * kept + 1]].entities[listed[3 * kept + 2]], state.archetypes[listed[3 * entry + 1]].entities[listed[3 * entry + 2]], state.archetypes[listed[3 * kept + 1]], listed[3 * kept + 2], failures); + } + return; + } + if (hash === null) return; + loadHashTable(hash, count, listed, keys, hashes, built.buckets, tick, entities.length); + if (!index.unique) return; + for (let bucket = 0; bucket < hash.count; bucket++) { + const length = lengthOf(hash, bucket); + for (let k = 1; k < length; k++) conflict(state, index, hash, bucket, hash.entries[(startOf(hash, bucket) + k) * entryWords], failures); + } +}; + +/** Entries below which `rebuild` builds an index: phases cost more than they save. */ +const poolMinimum = 1 << 16; + +const freePooled = (state: State, pooled: Pooled): void => { + for (const array of [ + pooled.grouped, pooled.groupedKeys, pooled.groupedHashes, pooled.local, pooled.first, pooled.lengths, pooled.overflow, pooled.histogram, + pooled.partitions, pooled.plan, + ]) freeArray(state.memory, array); +}; + +/** A pooled build's arrays for `count` entries and `pieces` pieces, those kept from the last when they're large enough. */ +const pooledFor = (state: State, index: WorldIndex, count: number, pieces: number): Pooled => { + const kept = index.pooled; + if (kept !== null && kept.groupedHashes.length >= count && kept.histogram.length >= pieces * maximumPartitions) return kept; + if (kept !== null) freePooled(state, kept); + const length = Math.max(count, 2 * (kept?.groupedHashes.length ?? 0)); + const piecesHeld = Math.max(pieces, 2 * (kept === null ? 0 : kept.histogram.length / maximumPartitions)); + const memory = state.memory; + const pooled: Pooled = { + grouped: allocateArray(memory, Int32Array, length * entryWords), + groupedKeys: allocateArray(memory, Uint32Array, Math.max(1, length * index.words)), + groupedHashes: allocateArray(memory, Uint32Array, length), + local: allocateArray(memory, Int32Array, length), + first: allocateArray(memory, Int32Array, length), + lengths: allocateArray(memory, Int32Array, length), + overflow: allocateArray(memory, Int32Array, length), + histogram: allocateArray(memory, Int32Array, piecesHeld * maximumPartitions), + partitions: allocateArray(memory, Int32Array, 7 * maximumPartitions + 1), + plan: allocateArray(memory, Float64Array, 64), + }; + index.pooled = pooled; + return pooled; +}; + +/** Writes a plan into the pooled build's plan block, growing it as needed; returns its byte address. */ +const placePlan = (state: State, pooled: Pooled, build: HashBuild): number => { + const words = planWords(build); + if (pooled.plan.length < words) { + freeArray(state.memory, pooled.plan); + pooled.plan = allocateArray(state.memory, Float64Array, Math.max(words, 2 * pooled.plan.length)); + } + writePlan(new Float64Array(state.memory.memory.buffer), pooled.plan.byteOffset >> 3, build); + return pooled.plan.byteOffset; +}; + +/** + * Runs a phase over `count` pieces or partitions, one at a time, claimed by the workers through their task rings; when + * a worker failed, the whole phase runs again here, which each phase allows. Resolves with whether the workers ran it. + */ +const runPhase = async (state: State, pool: Pool, phase: Phase, build: HashBuild, plan: number, count: number): Promise => { + if (count === 0) return true; + const results = await Promise.all(pool.claimed(0, pool.size, { kernel: buildKernel, archetype: phases.indexOf(phase), start: 0, end: count, tick: 0, lastRun: 0, scalars: [plan], partials: -1, partialBytes: 0 }, 1)); + if (results.every(result => result.error === undefined)) return true; + runBuildStep(buildViews(state.memory.memory.buffer), build, phase, 0, count); + return false; +}; + +/** A leaf's stored type's code, for a build's plan. */ +const codeOf = (scalar: keyof typeof scalarCodes): number => scalarCodes[scalar]; + +/** + * Builds a hash index again in phases over fixed pieces and partitions (`index-build.ts`): across the pool when there + * is one, its phases claimed by workers, else on this thread; either way the same index, whatever the number of + * workers. Null when it built here, or for an index it doesn't build: one in memory workers can't share, a sorted one, + * one with an object key part, or one too small or sparse to gain, which `rebuild` builds. + */ +const rebuildPhased = (state: State, index: WorldIndex, tick: number, failures: Failure[], pool: Pool | null): Promise | null | false => { + const { hash } = index; + if (hash === null || !state.memory.shared || index.parts.some(part => part.kind === "object")) return false; + refreshMembers(state, index); + let total = 0; + for (const archetype of index.members) total += archetype.count; + // Phases walk every entity index, as `rebuild` does unless few among many are members. + if (total < poolMinimum || total * Math.log2(total + 2) < state.entities.length) return false; + state.phasedBuilds++; + const build = startBuild(state, index, hash, total, tick); + const views = (): BuildViews => buildViews(state.memory.memory.buffer); + if (pool === null || pool.size === 0) { + runBuildStep(views(), build.listing, "count", 0, build.pieces); + placeGroups(state, build); + runBuildStep(views(), build.listing, "list", 0, build.pieces); + runBuildStep(views(), build.listing, "group", 0, build.partitions); + const filling = sizeTable(state, build); + runBuildStep(views(), filling, "fill", 0, build.partitions); + finishBuild(state, build, filling, failures); + return null; + } + state.pooledBuilds++; + return (async (): Promise => { + await runPhase(state, pool, "count", build.listing, build.plan, build.pieces); + placeGroups(state, build); + await runPhase(state, pool, "list", build.listing, build.plan, build.pieces); + await runPhase(state, pool, "group", build.listing, build.plan, build.partitions); + const filling = sizeTable(state, build); + if (!await runPhase(state, pool, "fill", filling, placePlan(state, build.pooled, filling), build.partitions) && filling.placing) { + // A fill that ran again here placed its partitions' buckets again: they're placed once more from empty slots. + hash.slots.fill(0); + runBuildStep(views(), filling, "fill", 0, build.partitions); + } + finishBuild(state, build, filling, failures); + })(); +}; + +/** A build in progress: its index, table and arrays, its plan for listing and grouping, and what grouping found. */ +type Build = { + readonly index: WorldIndex; + readonly hash: HashTable; + readonly pooled: Pooled; + readonly total: number; + readonly tick: number; + readonly pieces: number; + readonly partitions: number; + readonly slots: number; + readonly listing: HashBuild; + readonly plan: number; + readonly make: (placing: boolean) => HashBuild; + buckets: number; + capacity: number; + crowded: number; +}; + +/** Sizes the slots for as many buckets as the index last held and writes the plan for listing and grouping. */ +const startBuild = (state: State, index: WorldIndex, hash: HashTable, total: number, tick: number): Build => { + const { entities } = state; + const entityEnd = entities.length; + const pieces = Math.ceil(entityEnd / pieceEntities); + coverEntities(hash, entityEnd); + // Sized for as many buckets as it last held, as `loadHashTable` sizes it. + rehash(hash, Math.min(total, Math.max(16, hash.count - hash.freed.length - hash.empty))); + const pooled = pooledFor(state, index, total, pieces); + const slots = hash.slots.length >> 1; + const partitions = Math.min(maximumPartitions, slots); + const shift = Math.log2(slots) - Math.log2(partitions); + const members = new Array(state.archetypes.length).fill(-1); + const columns: number[] = []; + const leaves = index.parts.flatMap((part, p) => part.kind === "leaves" ? part.leaves.map(leaf => ({ p, part, leaf })) : []); + let wordAt = 0; + const leafWords = leaves.map(({ part, leaf }, k) => { + if (k === 0 || leaves[k - 1].part !== part) wordAt = part.first; + const at = wordAt; + wordAt += leaf.scalar === "f64" ? 2 : 1; + return at; + }); + index.members.forEach((archetype, slot) => { + members[archetype.id] = slot; + for (const { p } of leaves) columns.push(archetype.storageById[index.keys[p]]?.views.u8.byteOffset ?? 0); + }); + const at = pooled.partitions.byteOffset; + const make = (placing: boolean): HashBuild => ({ + entityArchetype: entities.archetype.byteOffset, + entityRow: entities.row.byteOffset, + entityEnd, + pieces, + words: index.words, + partitions, + shift, + mask: slots - 1, + histogram: pooled.histogram.byteOffset, + grouped: pooled.grouped.byteOffset, + groupedKeys: pooled.groupedKeys.byteOffset, + groupedHashes: pooled.groupedHashes.byteOffset, + local: pooled.local.byteOffset, + starts: at, + buckets: at + 4 * (maximumPartitions + 1), + capacity: at + 4 * (2 * maximumPartitions + 1), + crowded: at + 4 * (3 * maximumPartitions + 1), + bucketBase: at + 4 * (4 * maximumPartitions + 1), + capacityBase: at + 4 * (5 * maximumPartitions + 1), + overflowed: at + 4 * (6 * maximumPartitions + 1), + first: pooled.first.byteOffset, + lengths: pooled.lengths.byteOffset, + overflow: pooled.overflow.byteOffset, + slots: hash.slots.byteOffset, + records: hash.records.byteOffset, + stride: hash.stride, + ticks: hash.ticks.byteOffset, + table: hash.entries.byteOffset, + bucketOf: hash.bucketOf.byteOffset, + tick, + placing, + members, + columns, + leafCodes: leaves.map(({ leaf }) => codeOf(leaf.scalar)), + leafStrides: leaves.map(({ leaf }) => leaf.unitStride), + leafOffsets: leaves.map(({ leaf }) => leaf.unitOffset), + leafWords, + watched: [...hash.watched.values()].map(ticks => ticks.byteOffset), + }); + const listing = make(true); + return { index, hash, pooled, total, tick, pieces, partitions, slots, listing, plan: placePlan(state, pooled, listing), make, buckets: 0, capacity: 0, crowded: 0 }; +}; + +/** Each partition's start, and by partition then piece where each piece's entries of it go, from the counts. */ +const placeGroups = (state: State, build: Build): void => { + const { pieces, partitions, pooled } = build; + const histogram = new Int32Array(state.memory.memory.buffer, pooled.histogram.byteOffset, pieces * partitions); + const words = new Int32Array(state.memory.memory.buffer, pooled.partitions.byteOffset, 7 * maximumPartitions + 1); + let position = 0; + for (let q = 0; q < partitions; q++) { + words[q] = position; + for (let piece = 0; piece < pieces; piece++) { + const count = histogram[piece * partitions + q]; + histogram[piece * partitions + q] = position; + position += count; + } + } + words[partitions] = position; +}; + +/** Where each partition's buckets and segments start, the table grown for them; returns the plan for filling. */ +const sizeTable = (state: State, build: Build): HashBuild => { + const { partitions, pooled, hash } = build; + const words = new Int32Array(state.memory.memory.buffer, pooled.partitions.byteOffset, 7 * maximumPartitions + 1); + let buckets = 0; + let capacity = 0; + let crowded = 0; + for (let q = 0; q < partitions; q++) { + words[4 * maximumPartitions + 1 + q] = buckets; + words[5 * maximumPartitions + 1 + q] = capacity; + buckets += words[maximumPartitions + 1 + q]; + capacity += words[2 * maximumPartitions + 1 + q]; + crowded += words[3 * maximumPartitions + 1 + q]; + } + build.buckets = buckets; + build.capacity = capacity; + build.crowded = crowded; + coverBuckets(hash, buckets); + if (capacity * entryWords > hash.entries.length) { + freeArray(state.memory, hash.entries); + hash.entries = allocateArray(state.memory, Int32Array, Math.max(64, capacity) * entryWords); + } + // Buckets past what the slots hold are placed here once filled. + const filling = build.make(slotsHold(build.slots, buckets)); + placePlan(state, pooled, filling); + return filling; +}; + +/** The table's counts once filled, buckets past their partitions' slots placed, and a unique index's conflicts reported. */ +const finishBuild = (state: State, build: Build, filling: HashBuild, failures: Failure[]): void => { + const { hash, index, pooled, partitions, buckets, tick } = build; + hash.count = buckets; + hash.freed.length = 0; + hash.empty = 0; + hash.cleared = tick; + hash.size = build.total; + hash.used = build.capacity; + hash.garbage = 0; + if (!filling.placing) { + rehash(hash, buckets); + placeAll(hash); + } + else { + const words = new Int32Array(state.memory.memory.buffer, pooled.partitions.byteOffset, 7 * maximumPartitions + 1); + const overflow = new Int32Array(state.memory.memory.buffer, pooled.overflow.byteOffset, build.total); + for (let q = 0; q < partitions; q++) { + const overflowed = words[6 * maximumPartitions + 1 + q]; + for (let k = 0; k < overflowed; k++) { + const bucket = overflow[words[q] + k]; + place(hash.slots, bucket, hash.records[bucket * hash.stride + recordHash]); + } + } + } + for (const component of index.scanned.keys()) index.scanned.set(component, tick); + if (!index.unique || build.crowded === 0) return; + for (let bucket = 0; bucket < hash.count; bucket++) { + const length = lengthOf(hash, bucket); + for (let k = 1; k < length; k++) conflict(state, index, hash, bucket, hash.entries[(startOf(hash, bucket) + k) * entryWords], failures); + } +}; + +/** Whether entries `a` and `b` of a rebuild's keys have the same words. */ +const keyEquals = (keys: Uint32Array, words: number, a: number, b: number): boolean => { + for (let w = 0; w < words; w++) if (keys[a * words + w] !== keys[b * words + w]) return false; + return true; +}; + +/** + * A rebuild's entries in key order, stably, so equal keys keep entity order: by LSD radix sort over 16-bit digits of + * the words, skipping digits every entry shares; with object parts, by comparing their values. + */ +const sortedOrder = (index: WorldIndex, count: number, keys: Uint32Array): Int32Array => { + const { words, built } = index; + if (built.order.length < count) { + built.order = new Int32Array(Math.max(count, 2 * built.order.length)); + built.spare = new Int32Array(built.order.length); + } + let order = built.order; + let spare = built.spare; + for (let k = 0; k < count; k++) order[k] = k; + if (index.parts.some(part => part.kind === "object")) { + const values = index.parts.map(part => part.kind === "object" ? part.interned.values : null); + const ordered = Array.from(order.subarray(0, count)).sort((a, b) => { + for (let p = 0; p < index.parts.length; p++) { + const part = index.parts[p]; + if (part.kind === "object") { + const found = values[p]; + const compared = found === null ? 0 : compareObjects(found[keys[a * words + part.first]], found[keys[b * words + part.first]]); + if (compared !== 0) return compared; + continue; + } + if (part.kind === "constant") continue; + for (let w = part.first; w < part.first + part.words; w++) { + const x = keys[a * words + w]; + const y = keys[b * words + w]; + if (x !== y) return x < y ? -1 : 1; + } + } + return a - b; + }); + order.set(ordered); + return order; + } + const histogram = built.histogram; + for (let w = words - 1; w >= 0; w--) { + for (let shift = 0; shift < 32; shift += 16) { + histogram.fill(0); + for (let k = 0; k < count; k++) histogram[(keys[order[k] * words + w] >>> shift) & 0xFFFF]++; + // A digit every entry shares orders nothing. + if (histogram[(keys[order[0] * words + w] >>> shift) & 0xFFFF] === count) continue; + let sum = 0; + for (let digit = 0; digit < 65536; digit++) { + const next = sum + histogram[digit]; + histogram[digit] = sum; + sum = next; + } + for (let k = 0; k < count; k++) { + const entry = order[k]; + spare[histogram[(keys[entry * words + w] >>> shift) & 0xFFFF]++] = entry; + } + const swapped = order; + order = spare; + spare = swapped; + } + } + built.order = order; + built.spare = spare; + return order; +}; + +/** Stamps the buckets holding rows whose watched component changed since it was last read, with `tick`. */ +const scanWatched = (state: State, index: WorldIndex, component: number, tick: number): void => { + const since = index.scanned.get(component) ?? tick; + if (!writtenSince(state, component, since)) return; + if (index.keys.length === 0 && index.sorted !== null) { + scanWatchedWhole(index, index.sorted, component, since, tick); + return; + } + const ticks = index.hash?.watched.get(component)?? index.sorted?.watched.get(component)?.ticks; + if (ticks === undefined) return; + const holders = index.hash?.bucketOf ?? index.sorted?.blockOf; + if (holders === undefined) return; + for (const archetype of index.members) { + const storage = archetype.storageById[component]; + if (storage === undefined || !storage.ticked) continue; + const rows = archetype.entities; + changedRows(archetype, storage, since, row => { + const holder = holders[rows[row] >>> 0]; + if (holder >= 0) ticks[holder] = tick; + }); + } + if (index.sorted !== null) refreshWatchedTree(index.sorted, component); + index.scanned.set(component, tick); +}; + +/** + * `scanWatched` for an index without a key, whose lookups all give every row: a row whose watched component changed + * stamps one block, found by the ticks of chunks. + */ +const scanWatchedWhole = (index: WorldIndex, sorted: SortedTable, component: number, since: number, tick: number): void => { + index.scanned.set(component, tick); + for (const archetype of index.members) { + const storage = archetype.storageById[component]; + if (storage === undefined || !storage.ticked) continue; + let changed = storage.columnChanged > since; + const { chunkChanged } = storage; + for (let chunk = 0, count = Math.ceil(archetype.count / 2 ** chunkShift); !changed && chunk < count; chunk++) changed = chunkChanged[chunk] > since; + if (!changed) continue; + stampWatched(sorted, component, 0, tick); + return; + } +}; + +/** An index a runner looks up through, with the components its lookups there watch. */ +export type IndexUse = { readonly index: WorldIndex; readonly watching: readonly number[] }; + +/** The indexes a declaration's lookups go through and its rows are ordered by, acquired for one user: by lookup name, its order's, as uses, and their release. */ +export type IndexLookups = { + readonly byName: ReadonlyMap; + readonly ordered: WorldIndex | null; + readonly uses: readonly IndexUse[]; + /** The indexes of its lookups with `changed: true`, each once: their entries changing is a change filter. */ + readonly filters: readonly WorldIndex[]; + readonly release: () => void; +}; + +const noUses: readonly IndexUse[] = []; +const noIndexes: ReadonlyMap = new Map(); +/** What a declaration without lookups through indexes acquires. */ +const noFilters: readonly WorldIndex[] = []; +const noLookups: IndexLookups = { byName: noIndexes, ordered: null, uses: noUses, filters: noFilters, release: () => {} }; + +/** Acquires the indexes a declaration's lookups go through, watching what they watch, once each per index. */ +export const acquireLookups = (state: State, lookup: { readonly [name: string]: SystemLookup } | undefined, order?: unknown): IndexLookups => { + const ordering = indexDefinitionOf(order) ?? null; + if (lookup === undefined && ordering === null) return noLookups; + const byName = new Map(); + const watching = new Map>(); + const filters: WorldIndex[] = []; + const acquired = (found: IndexDefinition): WorldIndex => { + let index = [...watching.keys()].find(other => other.definition === found); + if (index === undefined) { + index = acquireIndex(state, found); + watching.set(index, new Set()); + } + return index; + }; + for (const name in lookup) { + const found = indexOfLookup(lookup[name]); + if (found === undefined) continue; + const index = acquired(found); + byName.set(name, index); + const declared = lookup[name]; + for (const key of watchedBy(declared)) watching.get(index)?.add(idOf(state, key)); + if (watchesEntries(declared) && !filters.includes(index)) filters.push(index); + } + const ordered = ordering === null ? null : acquired(ordering); + if (watching.size === 0) return noLookups; + const uses = [...watching].map(([index, components]): IndexUse => { + for (const component of components) watchIndex(state, index, component); + return { index, watching: [...components] }; + }); + let released = false; + return { + byName, + ordered, + uses, + filters: filters.length === 0 ? noFilters : filters, + release: () => { + if (released) return; + released = true; + for (const { index, watching: components } of uses) { + for (const component of components) unwatchIndex(index, component); + releaseIndex(state, index); + } + }, + }; +}; + +/** + * Before a run: starts ticks and kernels' stamps for what finalizers' indexes follow since the last one. Returns + * whether it did, so kernels that dropped those stamps load again. + */ +export const startWatches = (state: State): boolean => { + const pending = state.watchesPending; + if (pending.length === 0) return false; + startTicking(state, pending); + for (const id of pending) observe(state, id); + pending.length = 0; + return true; +}; diff --git a/packages/ecs/src/implementation/world/input.ts b/packages/ecs/src/implementation/world/input.ts new file mode 100644 index 000000000..0771ce07c --- /dev/null +++ b/packages/ecs/src/implementation/world/input.ts @@ -0,0 +1,14 @@ +import type { Leaf } from '../../interface/system/leaf.js'; + +/** The fields of a run's input that a leaf declares, so other members' fields aren't copied to its workers. */ +export const inputFor = (leaf: Leaf): (input: unknown) => unknown => { + const { input } = leaf.declaration; + if (input === undefined) return () => undefined; + const fields = Object.keys(input.properties); + return value => { + if (typeof value !== "object" || value === null) return value; + const own: { [field: string]: unknown } = {}; + for (const field of fields) if (Object.hasOwn(value, field)) own[field] = Reflect.get(value, field); + return own; + }; +}; diff --git a/packages/ecs/src/implementation/world/intrinsics-coverage.test.ts b/packages/ecs/src/implementation/world/intrinsics-coverage.test.ts new file mode 100644 index 000000000..c5117de70 --- /dev/null +++ b/packages/ecs/src/implementation/world/intrinsics-coverage.test.ts @@ -0,0 +1,25 @@ +/// +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from 'vitest'; + +const directory = fileURLToPath(new URL("../../interface/math/", import.meta.url)); +const filesIn = (path: string): string[] => readdirSync(path, { withFileTypes: true }) + .flatMap(entry => entry.isDirectory() ? filesIn(join(path, entry.name)) : entry.name.endsWith(".ts") && !entry.name.includes("test") ? [join(path, entry.name)] : []); + +/** A math file's function as kernels call it: `vec3/i32/add.ts` is `Vec3.I32.add`, `vec3/f32/add.ts` is `Vec3.add`. */ +const calledAs = (file: string): string => { + const [first = "", ...rest] = relative(directory, file).replace(/\.ts$/, "").split("/"); + const namespace = first === "mat4x4" ? "Mat4x4" : first.charAt(0).toUpperCase() + first.slice(1); + const name = (rest.pop() ?? "").replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase()); + const kind = rest[0] === "i32" || rest[0] === "u32" ? [rest[0].toUpperCase()] : []; + return [namespace, ...kind, name].join("."); +}; + +test("every function that declares a WGSL builtin is compared with it on the GPU", () => { + const declared = filesIn(directory).filter(file => readFileSync(file, "utf8").includes("intrinsic({")).map(calledAs); + const tested = readFileSync(fileURLToPath(new URL("./intrinsics.browser.test.ts", import.meta.url)), "utf8"); + expect(declared.length).toBeGreaterThan(200); + expect(declared.filter(name => !tested.includes(`= ${name}(`))).toEqual([]); +}); diff --git a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts new file mode 100644 index 000000000..dff0f0e3a --- /dev/null +++ b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts @@ -0,0 +1,584 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { F32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../interface/math/index.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; + +const f2a = Symbol("intrinsicsTest/f2a"); +const f2b = Symbol("intrinsicsTest/f2b"); +const f2p = Symbol("intrinsicsTest/f2p"); +const f2o = Symbol("intrinsicsTest/f2o"); +const i2a = Symbol("intrinsicsTest/i2a"); +const u2a = Symbol("intrinsicsTest/u2a"); +const i2b = Symbol("intrinsicsTest/i2b"); +const u2b = Symbol("intrinsicsTest/u2b"); +const i2o = Symbol("intrinsicsTest/i2o"); +const u2o = Symbol("intrinsicsTest/u2o"); +const f3a = Symbol("intrinsicsTest/f3a"); +const f3b = Symbol("intrinsicsTest/f3b"); +const f3p = Symbol("intrinsicsTest/f3p"); +const f3o = Symbol("intrinsicsTest/f3o"); +const i3a = Symbol("intrinsicsTest/i3a"); +const u3a = Symbol("intrinsicsTest/u3a"); +const i3b = Symbol("intrinsicsTest/i3b"); +const u3b = Symbol("intrinsicsTest/u3b"); +const i3o = Symbol("intrinsicsTest/i3o"); +const u3o = Symbol("intrinsicsTest/u3o"); +const f4a = Symbol("intrinsicsTest/f4a"); +const f4b = Symbol("intrinsicsTest/f4b"); +const f4p = Symbol("intrinsicsTest/f4p"); +const f4o = Symbol("intrinsicsTest/f4o"); +const i4a = Symbol("intrinsicsTest/i4a"); +const u4a = Symbol("intrinsicsTest/u4a"); +const i4b = Symbol("intrinsicsTest/i4b"); +const u4b = Symbol("intrinsicsTest/u4b"); +const i4o = Symbol("intrinsicsTest/i4o"); +const u4o = Symbol("intrinsicsTest/u4o"); +const fo = Symbol("intrinsicsTest/fo"); +const ma = Symbol("intrinsicsTest/ma"); +const mb = Symbol("intrinsicsTest/mb"); +const mo = Symbol("intrinsicsTest/mo"); +const qa = Symbol("intrinsicsTest/qa"); +const qb = Symbol("intrinsicsTest/qb"); +const qo = Symbol("intrinsicsTest/qo"); +const ua = Symbol("intrinsicsTest/ua"); +const uo = Symbol("intrinsicsTest/uo"); + +declare module "@adobe/ecs" { + export interface Components { + [f2a]: { type: Vec2 }; + [f2b]: { type: Vec2 }; + [f2p]: { type: Vec2 }; + [f2o]: { type: Vec2 }; + [i2a]: { type: Vec2.I32 }; + [u2a]: { type: Vec2.U32 }; + [i2b]: { type: Vec2.I32 }; + [u2b]: { type: Vec2.U32 }; + [i2o]: { type: Vec2.I32 }; + [u2o]: { type: Vec2.U32 }; + [f3a]: { type: Vec3 }; + [f3b]: { type: Vec3 }; + [f3p]: { type: Vec3 }; + [f3o]: { type: Vec3 }; + [i3a]: { type: Vec3.I32 }; + [u3a]: { type: Vec3.U32 }; + [i3b]: { type: Vec3.I32 }; + [u3b]: { type: Vec3.U32 }; + [i3o]: { type: Vec3.I32 }; + [u3o]: { type: Vec3.U32 }; + [f4a]: { type: Vec4 }; + [f4b]: { type: Vec4 }; + [f4p]: { type: Vec4 }; + [f4o]: { type: Vec4 }; + [i4a]: { type: Vec4.I32 }; + [u4a]: { type: Vec4.U32 }; + [i4b]: { type: Vec4.I32 }; + [u4b]: { type: Vec4.U32 }; + [i4o]: { type: Vec4.I32 }; + [u4o]: { type: Vec4.U32 }; + [fo]: { type: F32 }; + [ma]: { type: Mat4x4 }; + [mb]: { type: Mat4x4 }; + [mo]: { type: Mat4x4 }; + [qa]: { type: Quat }; + [qb]: { type: Quat }; + [qo]: { type: Quat }; + [ua]: { type: U32 }; + [uo]: { type: U32 }; + } +} + +Component.register(f2a, { type: Vec2.schema }); +Component.register(f2b, { type: Vec2.schema }); +Component.register(f2p, { type: Vec2.schema }); +Component.register(f2o, { type: Vec2.schema }); +Component.register(i2a, { type: Vec2.I32.schema }); +Component.register(u2a, { type: Vec2.U32.schema }); +Component.register(i2b, { type: Vec2.I32.schema }); +Component.register(u2b, { type: Vec2.U32.schema }); +Component.register(i2o, { type: Vec2.I32.schema }); +Component.register(u2o, { type: Vec2.U32.schema }); +Component.register(f3a, { type: Vec3.schema }); +Component.register(f3b, { type: Vec3.schema }); +Component.register(f3p, { type: Vec3.schema }); +Component.register(f3o, { type: Vec3.schema }); +Component.register(i3a, { type: Vec3.I32.schema }); +Component.register(u3a, { type: Vec3.U32.schema }); +Component.register(i3b, { type: Vec3.I32.schema }); +Component.register(u3b, { type: Vec3.U32.schema }); +Component.register(i3o, { type: Vec3.I32.schema }); +Component.register(u3o, { type: Vec3.U32.schema }); +Component.register(f4a, { type: Vec4.schema }); +Component.register(f4b, { type: Vec4.schema }); +Component.register(f4p, { type: Vec4.schema }); +Component.register(f4o, { type: Vec4.schema }); +Component.register(i4a, { type: Vec4.I32.schema }); +Component.register(u4a, { type: Vec4.U32.schema }); +Component.register(i4b, { type: Vec4.I32.schema }); +Component.register(u4b, { type: Vec4.U32.schema }); +Component.register(i4o, { type: Vec4.I32.schema }); +Component.register(u4o, { type: Vec4.U32.schema }); +Component.register(fo, { type: F32.schema }); +Component.register(ma, { type: Mat4x4.schema }); +Component.register(mb, { type: Mat4x4.schema }); +Component.register(mo, { type: Mat4x4.schema }); +Component.register(qa, { type: Quat.schema }); +Component.register(qb, { type: Quat.schema }); +Component.register(qo, { type: Quat.schema }); +Component.register(ua, { type: U32.schema }); +Component.register(uo, { type: U32.schema }); + +/** A value in [-1, 1) that differs per row and lane. */ +const unit = (row: number, lane: number): number => ((Math.imul(row * 31 + lane * 7 + 1, 0x9E3779B9) >>> 0) / 2 ** 32) * 2 - 1; +const float = (seed: number) => (row: number, lane: number): number => unit(row + seed, lane) * 0.9; +const positive = (row: number, lane: number): number => Math.abs(float(500)(row, lane)) + 0.1; +const integer = (seed: number) => (row: number, lane: number): number => Math.imul(row * 977 + lane * 131 + seed, 0x9E3779B9) | 0; +const natural = (seed: number) => (row: number, lane: number): number => integer(seed)(row, lane) >>> 0; +const two = (row: number, value: (row: number, lane: number) => number): readonly [number, number] => [value(row, 0), value(row, 1)]; +const three = (row: number, value: (row: number, lane: number) => number): readonly [number, number, number] => [value(row, 0), value(row, 1), value(row, 2)]; +const four = (row: number, value: (row: number, lane: number) => number): readonly [number, number, number, number] => [value(row, 0), value(row, 1), value(row, 2), value(row, 3)]; +const rows = 64; + +const spawn = System.create({ name: "intrinsicsTest/spawn" }, ({ commands }) => { + for (let i = 0; i < rows; i++) { + commands.spawn([f2a, f2b, f2p, f2o, i2a, i2b, i2o, u2a, u2b, u2o, f3a, f3b, f3p, f3o, i3a, i3b, i3o, u3a, u3b, u3o, f4a, f4b, f4p, f4o, i4a, i4b, i4o, u4a, u4b, u4o, fo, ma, mb, mo, qa, qb, qo, ua, uo])({ + [f2a]: two(i, float(0)), + [f2b]: two(i, float(100)), + [f2p]: two(i, positive), + [f2o]: two(i, float(0)), + [i2a]: two(i, integer(1)), + [i2b]: two(i, integer(2)), + [i2o]: two(i, integer(0)), + [u2a]: two(i, natural(3)), + [u2b]: two(i, natural(4)), + [u2o]: two(i, natural(0)), + [f3a]: three(i, float(0)), + [f3b]: three(i, float(100)), + [f3p]: three(i, positive), + [f3o]: three(i, float(0)), + [i3a]: three(i, integer(1)), + [i3b]: three(i, integer(2)), + [i3o]: three(i, integer(0)), + [u3a]: three(i, natural(3)), + [u3b]: three(i, natural(4)), + [u3o]: three(i, natural(0)), + [f4a]: four(i, float(0)), + [f4b]: four(i, float(100)), + [f4p]: four(i, positive), + [f4o]: four(i, float(0)), + [i4a]: four(i, integer(1)), + [i4b]: four(i, integer(2)), + [i4o]: four(i, integer(0)), + [u4a]: four(i, natural(3)), + [u4b]: four(i, natural(4)), + [u4o]: four(i, natural(0)), + [fo]: 0, + [ma]: Mat4x4.multiply(Mat4x4.translation(...three(i, float(200))), Mat4x4.rotationY(float(300)(i, 0))), + [mb]: Mat4x4.scaling(...three(i, positive)), + [mo]: Mat4x4.identity, + [qa]: Quat.normalize(four(i, float(400))), + [qb]: four(i, float(600)), + [qo]: Quat.identity, + [ua]: natural(5)(i, 0), + [uo]: 0, + }); + } +}); + +const operation = { type: "object", properties: { op: { type: "number" } } } as const; + +const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", input: operation, components: { read: [f2a, f2b, f2p], write: [f2o] } }, (row, { input: { op } }) => { + const a = row[f2a]; + const b = row[f2b]; + const p = row[f2p]; + if (op === 0) row[f2o] = Vec2.abs(a); + else if (op === 1) row[f2o] = Vec2.acos(a); + else if (op === 2) row[f2o] = Vec2.asin(a); + else if (op === 3) row[f2o] = Vec2.asinh(a); + else if (op === 4) row[f2o] = Vec2.atan(a); + else if (op === 5) row[f2o] = Vec2.atanh(a); + else if (op === 6) row[f2o] = Vec2.ceil(a); + else if (op === 7) row[f2o] = Vec2.cos(a); + else if (op === 8) row[f2o] = Vec2.cosh(a); + else if (op === 9) row[f2o] = Vec2.exp(a); + else if (op === 10) row[f2o] = Vec2.exp2(a); + else if (op === 11) row[f2o] = Vec2.floor(a); + else if (op === 12) row[f2o] = Vec2.fract(a); + else if (op === 13) row[f2o] = Vec2.negate(a); + else if (op === 14) row[f2o] = Vec2.normalize(a); + else if (op === 15) row[f2o] = Vec2.round(a); + else if (op === 16) row[f2o] = Vec2.sign(a); + else if (op === 17) row[f2o] = Vec2.sin(a); + else if (op === 18) row[f2o] = Vec2.sinh(a); + else if (op === 19) row[f2o] = Vec2.tan(a); + else if (op === 20) row[f2o] = Vec2.tanh(a); + else if (op === 21) row[f2o] = Vec2.trunc(a); + else if (op === 22) row[f2o] = Vec2.acosh(Vec2.add(p, [1, 1])); + else if (op === 23) row[f2o] = Vec2.log(p); + else if (op === 24) row[f2o] = Vec2.log2(p); + else if (op === 25) row[f2o] = Vec2.sqrt(p); + else if (op === 26) row[f2o] = Vec2.add(a, b); + else if (op === 27) row[f2o] = Vec2.subtract(a, b); + else if (op === 28) row[f2o] = Vec2.min(a, b); + else if (op === 29) row[f2o] = Vec2.max(a, b); + else if (op === 30) row[f2o] = Vec2.reflect(a, b); + else if (op === 31) row[f2o] = Vec2.step(a, b); + else if (op === 32) row[f2o] = Vec2.pow(p, b); + else if (op === 33) row[f2o] = Vec2.scale(a, -1.5); + else if (op === 34) row[f2o] = Vec2.mix(a, b, 0.25); + else if (op === 35) row[f2o] = Vec2.refract(Vec2.normalize(a), Vec2.normalize(b), 0.5); + else if (op === 36) row[f2o] = Vec2.clamp(a, [-0.5, -0.5], [0.5, 0.5]); + else if (op === 37) row[f2o] = Vec2.smoothstep([-1, -1], [1, 1], a); + else if (op === 38) row[f2o] = Vec2.faceforward(a, b, p); +}); + +const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: "gpu", input: operation, components: { read: [f2a, f2b], write: [fo] } }, (row, { input: { op } }) => { + const a = row[f2a]; + const b = row[f2b]; + if (op === 0) row[fo] = Vec2.dot(a, b); + else if (op === 1) row[fo] = Vec2.distance(a, b); + else if (op === 2) row[fo] = Vec2.length(a); +}); + +const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", input: operation, components: { read: [i2a, i2b], write: [i2o] } }, (row, { input: { op } }) => { + const a = row[i2a]; + const b = row[i2b]; + if (op === 0) row[i2o] = Vec2.I32.add(a, b); + else if (op === 1) row[i2o] = Vec2.I32.subtract(a, b); + else if (op === 2) row[i2o] = Vec2.I32.min(a, b); + else if (op === 3) row[i2o] = Vec2.I32.max(a, b); + else if (op === 4) row[i2o] = Vec2.I32.bitwiseAnd(a, b); + else if (op === 5) row[i2o] = Vec2.I32.bitwiseOr(a, b); + else if (op === 6) row[i2o] = Vec2.I32.bitwiseXor(a, b); + else if (op === 7) row[i2o] = Vec2.I32.negate(a); + else if (op === 8) row[i2o] = Vec2.I32.abs(a); + else if (op === 9) row[i2o] = Vec2.I32.bitwiseNot(a); + else if (op === 10) row[i2o] = Vec2.I32.countLeadingZeros(a); + else if (op === 11) row[i2o] = Vec2.I32.countOneBits(a); + else if (op === 12) row[i2o] = Vec2.I32.countTrailingZeros(a); + else if (op === 13) row[i2o] = Vec2.I32.reverseBits(a); + else if (op === 14) row[i2o] = Vec2.I32.scale(a, -3); + else if (op === 15) row[i2o] = Vec2.I32.clamp(a, [-1000, -1000], [1000, 1000]); +}); + +const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", input: operation, components: { read: [u2a, u2b], write: [u2o] } }, (row, { input: { op } }) => { + const a = row[u2a]; + const b = row[u2b]; + if (op === 0) row[u2o] = Vec2.U32.add(a, b); + else if (op === 1) row[u2o] = Vec2.U32.subtract(a, b); + else if (op === 2) row[u2o] = Vec2.U32.min(a, b); + else if (op === 3) row[u2o] = Vec2.U32.max(a, b); + else if (op === 4) row[u2o] = Vec2.U32.bitwiseAnd(a, b); + else if (op === 5) row[u2o] = Vec2.U32.bitwiseOr(a, b); + else if (op === 6) row[u2o] = Vec2.U32.bitwiseXor(a, b); + else if (op === 7) row[u2o] = Vec2.U32.bitwiseNot(a); + else if (op === 8) row[u2o] = Vec2.U32.countLeadingZeros(a); + else if (op === 9) row[u2o] = Vec2.U32.countOneBits(a); + else if (op === 10) row[u2o] = Vec2.U32.countTrailingZeros(a); + else if (op === 11) row[u2o] = Vec2.U32.reverseBits(a); + else if (op === 12) row[u2o] = Vec2.U32.scale(a, 3); + else if (op === 13) row[u2o] = Vec2.U32.clamp(a, [1000, 1000], [100000, 100000]); +}); + +const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", input: operation, components: { read: [f3a, f3b, f3p], write: [f3o] } }, (row, { input: { op } }) => { + const a = row[f3a]; + const b = row[f3b]; + const p = row[f3p]; + if (op === 0) row[f3o] = Vec3.abs(a); + else if (op === 1) row[f3o] = Vec3.acos(a); + else if (op === 2) row[f3o] = Vec3.asin(a); + else if (op === 3) row[f3o] = Vec3.asinh(a); + else if (op === 4) row[f3o] = Vec3.atan(a); + else if (op === 5) row[f3o] = Vec3.atanh(a); + else if (op === 6) row[f3o] = Vec3.ceil(a); + else if (op === 7) row[f3o] = Vec3.cos(a); + else if (op === 8) row[f3o] = Vec3.cosh(a); + else if (op === 9) row[f3o] = Vec3.exp(a); + else if (op === 10) row[f3o] = Vec3.exp2(a); + else if (op === 11) row[f3o] = Vec3.floor(a); + else if (op === 12) row[f3o] = Vec3.fract(a); + else if (op === 13) row[f3o] = Vec3.negate(a); + else if (op === 14) row[f3o] = Vec3.normalize(a); + else if (op === 15) row[f3o] = Vec3.round(a); + else if (op === 16) row[f3o] = Vec3.sign(a); + else if (op === 17) row[f3o] = Vec3.sin(a); + else if (op === 18) row[f3o] = Vec3.sinh(a); + else if (op === 19) row[f3o] = Vec3.tan(a); + else if (op === 20) row[f3o] = Vec3.tanh(a); + else if (op === 21) row[f3o] = Vec3.trunc(a); + else if (op === 22) row[f3o] = Vec3.acosh(Vec3.add(p, [1, 1, 1])); + else if (op === 23) row[f3o] = Vec3.log(p); + else if (op === 24) row[f3o] = Vec3.log2(p); + else if (op === 25) row[f3o] = Vec3.sqrt(p); + else if (op === 26) row[f3o] = Vec3.add(a, b); + else if (op === 27) row[f3o] = Vec3.subtract(a, b); + else if (op === 28) row[f3o] = Vec3.multiply(a, b); + else if (op === 29) row[f3o] = Vec3.min(a, b); + else if (op === 30) row[f3o] = Vec3.max(a, b); + else if (op === 31) row[f3o] = Vec3.reflect(a, b); + else if (op === 32) row[f3o] = Vec3.step(a, b); + else if (op === 33) row[f3o] = Vec3.cross(a, b); + else if (op === 34) row[f3o] = Vec3.pow(p, b); + else if (op === 35) row[f3o] = Vec3.scale(a, -1.5); + else if (op === 36) row[f3o] = Vec3.mix(a, b, 0.25); + else if (op === 37) row[f3o] = Vec3.refract(Vec3.normalize(a), Vec3.normalize(b), 0.5); + else if (op === 38) row[f3o] = Vec3.clamp(a, [-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]); + else if (op === 39) row[f3o] = Vec3.smoothstep([-1, -1, -1], [1, 1, 1], a); + else if (op === 40) row[f3o] = Vec3.faceforward(a, b, p); +}); + +const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: "gpu", input: operation, components: { read: [f3a, f3b], write: [fo] } }, (row, { input: { op } }) => { + const a = row[f3a]; + const b = row[f3b]; + if (op === 0) row[fo] = Vec3.dot(a, b); + else if (op === 1) row[fo] = Vec3.distance(a, b); + else if (op === 2) row[fo] = Vec3.length(a); +}); + +const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", input: operation, components: { read: [i3a, i3b], write: [i3o] } }, (row, { input: { op } }) => { + const a = row[i3a]; + const b = row[i3b]; + if (op === 0) row[i3o] = Vec3.I32.add(a, b); + else if (op === 1) row[i3o] = Vec3.I32.subtract(a, b); + else if (op === 2) row[i3o] = Vec3.I32.multiply(a, b); + else if (op === 3) row[i3o] = Vec3.I32.min(a, b); + else if (op === 4) row[i3o] = Vec3.I32.max(a, b); + else if (op === 5) row[i3o] = Vec3.I32.bitwiseAnd(a, b); + else if (op === 6) row[i3o] = Vec3.I32.bitwiseOr(a, b); + else if (op === 7) row[i3o] = Vec3.I32.bitwiseXor(a, b); + else if (op === 8) row[i3o] = Vec3.I32.negate(a); + else if (op === 9) row[i3o] = Vec3.I32.abs(a); + else if (op === 10) row[i3o] = Vec3.I32.bitwiseNot(a); + else if (op === 11) row[i3o] = Vec3.I32.countLeadingZeros(a); + else if (op === 12) row[i3o] = Vec3.I32.countOneBits(a); + else if (op === 13) row[i3o] = Vec3.I32.countTrailingZeros(a); + else if (op === 14) row[i3o] = Vec3.I32.reverseBits(a); + else if (op === 15) row[i3o] = Vec3.I32.scale(a, -3); + else if (op === 16) row[i3o] = Vec3.I32.clamp(a, [-1000, -1000, -1000], [1000, 1000, 1000]); +}); + +const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", input: operation, components: { read: [u3a, u3b], write: [u3o] } }, (row, { input: { op } }) => { + const a = row[u3a]; + const b = row[u3b]; + if (op === 0) row[u3o] = Vec3.U32.add(a, b); + else if (op === 1) row[u3o] = Vec3.U32.subtract(a, b); + else if (op === 2) row[u3o] = Vec3.U32.multiply(a, b); + else if (op === 3) row[u3o] = Vec3.U32.min(a, b); + else if (op === 4) row[u3o] = Vec3.U32.max(a, b); + else if (op === 5) row[u3o] = Vec3.U32.bitwiseAnd(a, b); + else if (op === 6) row[u3o] = Vec3.U32.bitwiseOr(a, b); + else if (op === 7) row[u3o] = Vec3.U32.bitwiseXor(a, b); + else if (op === 8) row[u3o] = Vec3.U32.bitwiseNot(a); + else if (op === 9) row[u3o] = Vec3.U32.countLeadingZeros(a); + else if (op === 10) row[u3o] = Vec3.U32.countOneBits(a); + else if (op === 11) row[u3o] = Vec3.U32.countTrailingZeros(a); + else if (op === 12) row[u3o] = Vec3.U32.reverseBits(a); + else if (op === 13) row[u3o] = Vec3.U32.scale(a, 3); + else if (op === 14) row[u3o] = Vec3.U32.clamp(a, [1000, 1000, 1000], [100000, 100000, 100000]); +}); + +const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", input: operation, components: { read: [f4a, f4b, f4p], write: [f4o] } }, (row, { input: { op } }) => { + const a = row[f4a]; + const b = row[f4b]; + const p = row[f4p]; + if (op === 0) row[f4o] = Vec4.abs(a); + else if (op === 1) row[f4o] = Vec4.acos(a); + else if (op === 2) row[f4o] = Vec4.asin(a); + else if (op === 3) row[f4o] = Vec4.asinh(a); + else if (op === 4) row[f4o] = Vec4.atan(a); + else if (op === 5) row[f4o] = Vec4.atanh(a); + else if (op === 6) row[f4o] = Vec4.ceil(a); + else if (op === 7) row[f4o] = Vec4.cos(a); + else if (op === 8) row[f4o] = Vec4.cosh(a); + else if (op === 9) row[f4o] = Vec4.exp(a); + else if (op === 10) row[f4o] = Vec4.exp2(a); + else if (op === 11) row[f4o] = Vec4.floor(a); + else if (op === 12) row[f4o] = Vec4.fract(a); + else if (op === 13) row[f4o] = Vec4.negate(a); + else if (op === 14) row[f4o] = Vec4.normalize(a); + else if (op === 15) row[f4o] = Vec4.round(a); + else if (op === 16) row[f4o] = Vec4.sign(a); + else if (op === 17) row[f4o] = Vec4.sin(a); + else if (op === 18) row[f4o] = Vec4.sinh(a); + else if (op === 19) row[f4o] = Vec4.tan(a); + else if (op === 20) row[f4o] = Vec4.tanh(a); + else if (op === 21) row[f4o] = Vec4.trunc(a); + else if (op === 22) row[f4o] = Vec4.acosh(Vec4.add(p, [1, 1, 1, 1])); + else if (op === 23) row[f4o] = Vec4.log(p); + else if (op === 24) row[f4o] = Vec4.log2(p); + else if (op === 25) row[f4o] = Vec4.sqrt(p); + else if (op === 26) row[f4o] = Vec4.add(a, b); + else if (op === 27) row[f4o] = Vec4.subtract(a, b); + else if (op === 28) row[f4o] = Vec4.multiply(a, b); + else if (op === 29) row[f4o] = Vec4.min(a, b); + else if (op === 30) row[f4o] = Vec4.max(a, b); + else if (op === 31) row[f4o] = Vec4.reflect(a, b); + else if (op === 32) row[f4o] = Vec4.step(a, b); + else if (op === 33) row[f4o] = Vec4.pow(p, b); + else if (op === 34) row[f4o] = Vec4.scale(a, -1.5); + else if (op === 35) row[f4o] = Vec4.mix(a, b, 0.25); + else if (op === 36) row[f4o] = Vec4.refract(Vec4.normalize(a), Vec4.normalize(b), 0.5); + else if (op === 37) row[f4o] = Vec4.clamp(a, [-0.5, -0.5, -0.5, -0.5], [0.5, 0.5, 0.5, 0.5]); + else if (op === 38) row[f4o] = Vec4.smoothstep([-1, -1, -1, -1], [1, 1, 1, 1], a); + else if (op === 39) row[f4o] = Vec4.faceforward(a, b, p); +}); + +const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: "gpu", input: operation, components: { read: [f4a, f4b], write: [fo] } }, (row, { input: { op } }) => { + const a = row[f4a]; + const b = row[f4b]; + if (op === 0) row[fo] = Vec4.dot(a, b); + else if (op === 1) row[fo] = Vec4.distance(a, b); + else if (op === 2) row[fo] = Vec4.length(a); +}); + +const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", input: operation, components: { read: [i4a, i4b], write: [i4o] } }, (row, { input: { op } }) => { + const a = row[i4a]; + const b = row[i4b]; + if (op === 0) row[i4o] = Vec4.I32.add(a, b); + else if (op === 1) row[i4o] = Vec4.I32.subtract(a, b); + else if (op === 2) row[i4o] = Vec4.I32.multiply(a, b); + else if (op === 3) row[i4o] = Vec4.I32.min(a, b); + else if (op === 4) row[i4o] = Vec4.I32.max(a, b); + else if (op === 5) row[i4o] = Vec4.I32.bitwiseAnd(a, b); + else if (op === 6) row[i4o] = Vec4.I32.bitwiseOr(a, b); + else if (op === 7) row[i4o] = Vec4.I32.bitwiseXor(a, b); + else if (op === 8) row[i4o] = Vec4.I32.negate(a); + else if (op === 9) row[i4o] = Vec4.I32.abs(a); + else if (op === 10) row[i4o] = Vec4.I32.bitwiseNot(a); + else if (op === 11) row[i4o] = Vec4.I32.countLeadingZeros(a); + else if (op === 12) row[i4o] = Vec4.I32.countOneBits(a); + else if (op === 13) row[i4o] = Vec4.I32.countTrailingZeros(a); + else if (op === 14) row[i4o] = Vec4.I32.reverseBits(a); + else if (op === 15) row[i4o] = Vec4.I32.scale(a, -3); + else if (op === 16) row[i4o] = Vec4.I32.clamp(a, [-1000, -1000, -1000, -1000], [1000, 1000, 1000, 1000]); +}); + +const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", input: operation, components: { read: [u4a, u4b], write: [u4o] } }, (row, { input: { op } }) => { + const a = row[u4a]; + const b = row[u4b]; + if (op === 0) row[u4o] = Vec4.U32.add(a, b); + else if (op === 1) row[u4o] = Vec4.U32.subtract(a, b); + else if (op === 2) row[u4o] = Vec4.U32.multiply(a, b); + else if (op === 3) row[u4o] = Vec4.U32.min(a, b); + else if (op === 4) row[u4o] = Vec4.U32.max(a, b); + else if (op === 5) row[u4o] = Vec4.U32.bitwiseAnd(a, b); + else if (op === 6) row[u4o] = Vec4.U32.bitwiseOr(a, b); + else if (op === 7) row[u4o] = Vec4.U32.bitwiseXor(a, b); + else if (op === 8) row[u4o] = Vec4.U32.bitwiseNot(a); + else if (op === 9) row[u4o] = Vec4.U32.countLeadingZeros(a); + else if (op === 10) row[u4o] = Vec4.U32.countOneBits(a); + else if (op === 11) row[u4o] = Vec4.U32.countTrailingZeros(a); + else if (op === 12) row[u4o] = Vec4.U32.reverseBits(a); + else if (op === 13) row[u4o] = Vec4.U32.scale(a, 3); + else if (op === 14) row[u4o] = Vec4.U32.clamp(a, [1000, 1000, 1000, 1000], [100000, 100000, 100000, 100000]); +}); + +const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", input: operation, components: { read: [ma, mb], write: [mo] } }, (row, { input: { op } }) => { + const a = row[ma]; + const b = row[mb]; + if (op === 0) row[mo] = Mat4x4.multiply(a, b); + else if (op === 1) row[mo] = Mat4x4.transpose(a); + else if (op === 2) row[mo] = Mat4x4.add(a, b); + else if (op === 3) row[mo] = Mat4x4.subtract(a, b); + else if (op === 4) row[mo] = Mat4x4.scale(a, 0.5); +}); + +const mat4x4Vector = System.create({ name: "intrinsicsTest/mat4x4Vector", kernel: "gpu", input: operation, components: { read: [ma, f4a], write: [f4o] } }, (row, { input: { op } }) => { + const a = row[ma]; + const b = row[f4a]; + if (op === 0) row[f4o] = Mat4x4.multiplyVec4(a, b); +}); + +const mat4x4Scalar = System.create({ name: "intrinsicsTest/mat4x4Scalar", kernel: "gpu", input: operation, components: { read: [ma], write: [fo] } }, (row, { input: { op } }) => { + const a = row[ma]; + if (op === 0) row[fo] = Mat4x4.determinant(a); +}); + +const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", input: operation, components: { read: [qa, qb], write: [qo] } }, (row, { input: { op } }) => { + const a = row[qa]; + const b = row[qb]; + if (op === 0) row[qo] = Quat.add(a, b); + else if (op === 1) row[qo] = Quat.subtract(a, b); + else if (op === 2) row[qo] = Quat.scale(a, 2); + else if (op === 3) row[qo] = Quat.negate(a); +}); + +const quatScalar = System.create({ name: "intrinsicsTest/quatScalar", kernel: "gpu", input: operation, components: { read: [qa, qb], write: [fo] } }, (row, { input: { op } }) => { + const a = row[qa]; + const b = row[qb]; + if (op === 0) row[fo] = Quat.dot(a, b); + else if (op === 1) row[fo] = Quat.length(a); +}); + +const u32 = System.create({ name: "intrinsicsTest/u32", kernel: "gpu", input: operation, components: { read: [ua], write: [uo] } }, (row, { input: { op } }) => { + const a = row[ua]; + if (op === 0) row[uo] = U32.countLeadingZeros(a); + else if (op === 1) row[uo] = U32.countOneBits(a); + else if (op === 2) row[uo] = U32.countTrailingZeros(a); + else if (op === 3) row[uo] = U32.reverseBits(a); +}); + +/** A class of declared builtins: its kernel, the functions it selects between by `op`, and what it writes. */ +const testCase = (system: S, operations: readonly string[], output: O, exact: boolean) => ({ system, operations, output, exact }); + +const cases = [ + testCase(vec2f, ["Vec2.abs", "Vec2.acos", "Vec2.asin", "Vec2.asinh", "Vec2.atan", "Vec2.atanh", "Vec2.ceil", "Vec2.cos", "Vec2.cosh", "Vec2.exp", "Vec2.exp2", "Vec2.floor", "Vec2.fract", "Vec2.negate", "Vec2.normalize", "Vec2.round", "Vec2.sign", "Vec2.sin", "Vec2.sinh", "Vec2.tan", "Vec2.tanh", "Vec2.trunc", "Vec2.acosh", "Vec2.log", "Vec2.log2", "Vec2.sqrt", "Vec2.add", "Vec2.subtract", "Vec2.min", "Vec2.max", "Vec2.reflect", "Vec2.step", "Vec2.pow", "Vec2.scale", "Vec2.mix", "Vec2.refract", "Vec2.clamp", "Vec2.smoothstep", "Vec2.faceforward"], f2o, false), + testCase(vec2fScalar, ["Vec2.dot", "Vec2.distance", "Vec2.length"], fo, false), + testCase(vec2i, ["Vec2.I32.add", "Vec2.I32.subtract", "Vec2.I32.min", "Vec2.I32.max", "Vec2.I32.bitwiseAnd", "Vec2.I32.bitwiseOr", "Vec2.I32.bitwiseXor", "Vec2.I32.negate", "Vec2.I32.abs", "Vec2.I32.bitwiseNot", "Vec2.I32.countLeadingZeros", "Vec2.I32.countOneBits", "Vec2.I32.countTrailingZeros", "Vec2.I32.reverseBits", "Vec2.I32.scale", "Vec2.I32.clamp"], i2o, true), + testCase(vec2u, ["Vec2.U32.add", "Vec2.U32.subtract", "Vec2.U32.min", "Vec2.U32.max", "Vec2.U32.bitwiseAnd", "Vec2.U32.bitwiseOr", "Vec2.U32.bitwiseXor", "Vec2.U32.bitwiseNot", "Vec2.U32.countLeadingZeros", "Vec2.U32.countOneBits", "Vec2.U32.countTrailingZeros", "Vec2.U32.reverseBits", "Vec2.U32.scale", "Vec2.U32.clamp"], u2o, true), + testCase(vec3f, ["Vec3.abs", "Vec3.acos", "Vec3.asin", "Vec3.asinh", "Vec3.atan", "Vec3.atanh", "Vec3.ceil", "Vec3.cos", "Vec3.cosh", "Vec3.exp", "Vec3.exp2", "Vec3.floor", "Vec3.fract", "Vec3.negate", "Vec3.normalize", "Vec3.round", "Vec3.sign", "Vec3.sin", "Vec3.sinh", "Vec3.tan", "Vec3.tanh", "Vec3.trunc", "Vec3.acosh", "Vec3.log", "Vec3.log2", "Vec3.sqrt", "Vec3.add", "Vec3.subtract", "Vec3.multiply", "Vec3.min", "Vec3.max", "Vec3.reflect", "Vec3.step", "Vec3.cross", "Vec3.pow", "Vec3.scale", "Vec3.mix", "Vec3.refract", "Vec3.clamp", "Vec3.smoothstep", "Vec3.faceforward"], f3o, false), + testCase(vec3fScalar, ["Vec3.dot", "Vec3.distance", "Vec3.length"], fo, false), + testCase(vec3i, ["Vec3.I32.add", "Vec3.I32.subtract", "Vec3.I32.multiply", "Vec3.I32.min", "Vec3.I32.max", "Vec3.I32.bitwiseAnd", "Vec3.I32.bitwiseOr", "Vec3.I32.bitwiseXor", "Vec3.I32.negate", "Vec3.I32.abs", "Vec3.I32.bitwiseNot", "Vec3.I32.countLeadingZeros", "Vec3.I32.countOneBits", "Vec3.I32.countTrailingZeros", "Vec3.I32.reverseBits", "Vec3.I32.scale", "Vec3.I32.clamp"], i3o, true), + testCase(vec3u, ["Vec3.U32.add", "Vec3.U32.subtract", "Vec3.U32.multiply", "Vec3.U32.min", "Vec3.U32.max", "Vec3.U32.bitwiseAnd", "Vec3.U32.bitwiseOr", "Vec3.U32.bitwiseXor", "Vec3.U32.bitwiseNot", "Vec3.U32.countLeadingZeros", "Vec3.U32.countOneBits", "Vec3.U32.countTrailingZeros", "Vec3.U32.reverseBits", "Vec3.U32.scale", "Vec3.U32.clamp"], u3o, true), + testCase(vec4f, ["Vec4.abs", "Vec4.acos", "Vec4.asin", "Vec4.asinh", "Vec4.atan", "Vec4.atanh", "Vec4.ceil", "Vec4.cos", "Vec4.cosh", "Vec4.exp", "Vec4.exp2", "Vec4.floor", "Vec4.fract", "Vec4.negate", "Vec4.normalize", "Vec4.round", "Vec4.sign", "Vec4.sin", "Vec4.sinh", "Vec4.tan", "Vec4.tanh", "Vec4.trunc", "Vec4.acosh", "Vec4.log", "Vec4.log2", "Vec4.sqrt", "Vec4.add", "Vec4.subtract", "Vec4.multiply", "Vec4.min", "Vec4.max", "Vec4.reflect", "Vec4.step", "Vec4.pow", "Vec4.scale", "Vec4.mix", "Vec4.refract", "Vec4.clamp", "Vec4.smoothstep", "Vec4.faceforward"], f4o, false), + testCase(vec4fScalar, ["Vec4.dot", "Vec4.distance", "Vec4.length"], fo, false), + testCase(vec4i, ["Vec4.I32.add", "Vec4.I32.subtract", "Vec4.I32.multiply", "Vec4.I32.min", "Vec4.I32.max", "Vec4.I32.bitwiseAnd", "Vec4.I32.bitwiseOr", "Vec4.I32.bitwiseXor", "Vec4.I32.negate", "Vec4.I32.abs", "Vec4.I32.bitwiseNot", "Vec4.I32.countLeadingZeros", "Vec4.I32.countOneBits", "Vec4.I32.countTrailingZeros", "Vec4.I32.reverseBits", "Vec4.I32.scale", "Vec4.I32.clamp"], i4o, true), + testCase(vec4u, ["Vec4.U32.add", "Vec4.U32.subtract", "Vec4.U32.multiply", "Vec4.U32.min", "Vec4.U32.max", "Vec4.U32.bitwiseAnd", "Vec4.U32.bitwiseOr", "Vec4.U32.bitwiseXor", "Vec4.U32.bitwiseNot", "Vec4.U32.countLeadingZeros", "Vec4.U32.countOneBits", "Vec4.U32.countTrailingZeros", "Vec4.U32.reverseBits", "Vec4.U32.scale", "Vec4.U32.clamp"], u4o, true), + testCase(mat4x4, ["Mat4x4.multiply", "Mat4x4.transpose", "Mat4x4.add", "Mat4x4.subtract", "Mat4x4.scale"], mo, false), + testCase(mat4x4Vector, ["Mat4x4.multiplyVec4"], f4o, false), + testCase(mat4x4Scalar, ["Mat4x4.determinant"], fo, false), + testCase(quat, ["Quat.add", "Quat.subtract", "Quat.scale", "Quat.negate"], qo, false), + testCase(quatScalar, ["Quat.dot", "Quat.length"], fo, false), + testCase(u32, ["U32.countLeadingZeros", "U32.countOneBits", "U32.countTrailingZeros", "U32.reverseBits"], uo, true), +]; + +/** + * How far a GPU result may be from the CPU's: WGSL bounds `sin` and `cos` only by an absolute error of 2^-11 on [-π, π], + * which the inputs, within 0.9 of zero, keep to; software adapters such as SwiftShader use that margin. + */ +const toleranceOf = (operation: string): number => /\.(sin|cos)$/.test(operation) ? 2 ** -11 : 1e-4; + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +describe("declared WGSL builtins", () => { + it("give on the GPU what their functions give on the CPU", async () => { + const gpuWorld = createWorld({ [gpuDevice]: await device() }); + const cpuWorld = createWorld({ [workers]: 0 }); + for (const world of [gpuWorld, cpuWorld]) { + await world.run(spawn); + } + const values = async (world: ReturnType, output: (typeof cases)[number]["output"]): Promise => + (await readEach(world, await select(world, { required: [output] }), output)).flatMap((value: unknown) => + Array.isArray(value) ? value.map(Number) : [Number(value)]); + const mismatches: string[] = []; + for (const { system, operations, output, exact } of cases) { + for (let op = 0; op < operations.length; op++) { + for (const world of [gpuWorld, cpuWorld]) await world.run(system, { op }); + const gpu = await values(gpuWorld, output); + const cpu = await values(cpuWorld, output); + const worst = Math.max(...gpu.map((value, i) => exact ? (value === cpu[i] ? 0 : Infinity) : Math.abs(value - (cpu[i] ?? NaN)) / Math.max(1, Math.abs(cpu[i] ?? NaN)))); + if (gpu.length !== cpu.length || !(worst <= toleranceOf(operations[op]))) mismatches.push(`${operations[op]}: ${worst}`); + } + } + expect(mismatches).toEqual([]); + await gpuWorld.dispose(); + await cpuWorld.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/intrinsics.ts b/packages/ecs/src/implementation/world/intrinsics.ts new file mode 100644 index 000000000..c7e0cf2fa --- /dev/null +++ b/packages/ecs/src/implementation/world/intrinsics.ts @@ -0,0 +1,40 @@ +import type { Program } from './kernel.js'; + +type Expression = Program.Expression; +type Statement = Program.Statement; + +/** Each WGSL value type's scalar lanes: one per element of a vector, sixteen for a matrix. */ +const lanes: { readonly [T in Program.ValueType]: number } = { + f32: 1, i32: 1, u32: 1, vec2f: 2, vec3f: 3, vec4f: 4, vec2i: 2, vec3i: 3, vec4i: 4, vec2u: 2, vec3u: 3, vec4u: 4, mat4x4f: 16, +}; + +export const laneCount = (type: Program.ValueType): number => lanes[type]; + +export const isValueType = (text: string): text is Program.ValueType => Object.hasOwn(lanes, text); + +const expression = (node: Expression): Expression => { + switch (node.kind) { + case "lane": return expression(node.value); + case "unary": return { ...node, operand: expression(node.operand) }; + case "binary": case "logical": return { ...node, left: expression(node.left), right: expression(node.right) }; + case "conditional": return { ...node, test: expression(node.test), consequent: expression(node.consequent), alternate: expression(node.alternate) }; + case "math": return { ...node, arguments: node.arguments.map(expression) }; + default: return node; + } +}; + +const statements = (body: readonly Statement[]): Statement[] => body.flatMap((node): Statement[] => { + switch (node.kind) { + case "intrinsic": return statements(node.body); + case "set": case "store": return [{ ...node, value: expression(node.value) }]; + case "if": return [{ ...node, test: expression(node.test), consequent: statements(node.consequent), alternate: statements(node.alternate) }]; + case "block": case "loop": return [{ ...node, body: statements(node.body) }]; + case "branchIf": return [{ ...node, test: expression(node.test) }]; + case "find": return [{ ...node, entity: expression(node.entity) }]; + default: return [node]; + } +}); + +/** The program with each intrinsic region replaced by its body, for backends that don't emit intrinsics. */ +export const withoutIntrinsics = (program: Program): Program => + (program.intrinsics ?? 0) === 0 ? program : { ...program, intrinsics: 0, body: statements(program.body) }; diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts new file mode 100644 index 000000000..cd0b95f85 --- /dev/null +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { labelOfItem } from "../../interface/registry/index.js"; +import { Observe } from "../../observe/index.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { reportOf, runReported } from "./reports.test-helper.js"; + +// A world and a thread joining it through a MessageChannel, both on this thread: the protocol without workers. + +const point = Symbol("joinTest/point"); +const label = Symbol("joinTest/label"); +const marked = Symbol("joinTest/marked"); +const total = Symbol("joinTest/total"); +const names = Symbol("joinTest/names"); +const words = Symbol("joinTest/words"); +const counts = Symbol("joinTest/counts"); +declare module "@adobe/ecs" { + export interface Components { + [point]: { type: readonly [number, number] }; + [label]: { type: string }; + [marked]: { type: true }; + [words]: { type: ReadonlySet }; + } + export interface Resources { + [total]: { type: number }; + [names]: { type: readonly string[] }; + [counts]: { type: ReadonlyMap }; + } +} +Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(label, { type: { type: "string" } }); +Component.register(marked, { type: { const: true } }); +Resource.register(total, { type: { type: "number", default: 0 } }); +Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); +Component.register(words, { type: { type: "set", items: { type: "string" } } }); +Resource.register(counts, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); + +/** Adds its struct input to `total`, and its object input's names to `names`. */ +const add = System.create({ name: "joinTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { + resources[total] = resources[total] * 10 + input.amount; +}); +const append = System.create({ name: "joinTest/append", input: { type: "object", properties: { name: { type: "string" } } }, resources: { write: [names] } }, ({ input, resources }) => { + resources[names] = [...resources[names], input.name]; +}); +const spawn = System.create({ name: "joinTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { + commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); +}); +/** Counts each word of its input's set in `counts`, and spawns an entity holding the set. */ +const tally = System.create({ name: "joinTest/tally", input: { type: "object", properties: { words: { type: "set", items: { type: "string" } } } }, resources: { write: [counts] } }, ({ input, resources, commands }) => { + const next = new Map(resources[counts]); + for (const word of input.words) next.set(word, (next.get(word) ?? 0) + 1); + resources[counts] = next; + commands.spawn([words])({ [words]: input.words }); +}); +const fail = System.create({ name: "joinTest/fail" }, () => { throw new Error("joinTest failure"); }); +const idle = System.create({ name: "joinTest/idle" }, () => {}); + +/** A world shared with a thread joined through a channel, and the joined world. */ +const joined = (threads: readonly ("main")[] = []): { readonly world: CreatedWorld; readonly remote: World; readonly channel: MessageChannel } => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads }); + return { world, remote, channel }; +}; + +/** The next value an observer gives that `accept` takes. */ +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let stop: (() => void) | null = null; + let done = false; + stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop?.()); + resolve(value); + }); + if (done) stop(); +}); + +/** The progress of a thread, by the order it was shared in, once its status is `status`. */ +const statusOf = (world: CreatedWorld, shared: number, status: "joining" | "joined" | "gone") => + until(world.threads, threads => threads[shared]?.status === status).then(threads => threads[shared]); + +describe("joining", () => { + it("keeps a joined thread's progress and claims", async () => { + const { world, remote } = joined(["main"]); + expect(await statusOf(world, 0, "joined")).toEqual({ status: "joined", claims: ["main"] }); + await remote.dispose(); + expect(await statusOf(world, 0, "gone")).toEqual({ status: "gone", claims: ["main"] }); + await world.dispose(); + }); + it("lands runs from a joined thread in call order, with struct and object input, each its own sync point", async () => { + const { world, remote } = joined(); + const runs = [remote.run(add, { amount: 1 }), remote.run(append, { name: "a" }), remote.run(add, { amount: 2 }), remote.run(append, { name: "b" }), remote.run(add, { amount: 3 })]; + const reports = await Promise.all((await Promise.all(runs)).map(run => reportOf(world, run))); + expect(reports).toEqual(runs.map(() => ({ failures: [], skipped: [], dropped: 0, warnings: [] }))); + expect(await Observe.toPromise(Observe.resource(world, total))).toBe(123); + expect(await Observe.toPromise(Observe.resource(world, names))).toEqual(["a", "b"]); + await remote.run(spawn, { x: 4 }); + expect(await Observe.toPromise(Observe.count(world, { required: [point] }))).toBe(1); + await remote.dispose(); + await world.dispose(); + }); + it("posts a run's set input to the world as a set, which a map resource and a set component hold", async () => { + const { world, remote } = joined(); + expect(await Observe.toPromise(Observe.resource(world, counts))).toEqual(new Map()); + await remote.run(tally, { words: new Set(["a", "b"]) }); + await remote.run(tally, { words: new Set(["b"]) }); + expect(await Observe.toPromise(Observe.resource(world, counts))).toEqual(new Map([["a", 1], ["b", 2]])); + const held = await Observe.toPromise(Observe.select(world, { required: [words] })); + const values = await Promise.all(held.map(async entity => (await Observe.toPromise(Observe.entity(world, entity, { required: [words] })))?.[words])); + expect(values).toEqual([new Set(["a", "b"]), new Set(["b"])]); + expect(values.every(value => value instanceof Set)).toBe(true); + await remote.dispose(); + await world.dispose(); + await using started = createWorld({ [workers]: 0, [counts]: new Map([["z", 1]]) }); + expect(await Observe.toPromise(Observe.resource(started, counts))).toEqual(new Map([["z", 1]])); + }); + it("keeps runs in call order past the slots it holds room for, and rejects one whose input can't be posted", async () => { + const { world, remote } = joined(); + const runs = Array.from({ length: 200 }, (_, k) => k % 3 === 0 ? remote.run(append, { name: String(k) }) : remote.run(add, { amount: k % 10 })); + // Behind the runs filling its slots, so it's posted once they complete. + const unposted: { name: unknown } = { name: () => "a function" }; + const failed = Reflect.apply(remote.run, remote, [append, unposted]); + const after = remote.run(append, { name: "after" }); + await Promise.all(runs); + await expect(failed).rejects.toThrow(); + await after; + const expected = Array.from({ length: 200 }, (_, k) => k).filter(k => k % 3 === 0).map(String); + expect(await Observe.toPromise(Observe.resource(world, names))).toEqual([...expected, "after"]); + await remote.dispose(); + await world.dispose(); + }); + it("resolves a run that failed with its number, its report the world's, and rejects a run of a system without an id", async () => { + const { world, remote } = joined(); + const report = await reportOf(world, await remote.run(fail)); + expect(report.failures.map(failure => failure.message)).toEqual(["joinTest failure"]); + const local = System.create(() => {}); + await expect(remote.run(local)).rejects.toThrow(`System "${labelOfItem(local)}" has no id, so it can't run from another thread`); + expect(await reportOf(world, await remote.run(idle))).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + await remote.dispose(); + await world.dispose(); + }); + it("refuses a thread claimed twice: its runs reject, and its status is gone", async () => { + const world = createWorld({ [workers]: 0 }); + const first = new MessageChannel(); + const second = new MessageChannel(); + world.share(first.port1); + world.share(second.port1); + const one = join(first.port2, { threads: ["main"] }); + const two = join(second.port2, { threads: ["main"] }); + await expect(two.run(idle)).rejects.toThrow('Thread "main" is claimed twice.'); + expect(await statusOf(world, 1, "gone")).toEqual({ status: "gone", claims: [] }); + expect(await reportOf(world, await one.run(idle))).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + await one.dispose(); + await two.dispose(); + await world.dispose(); + }); + it("rejects a joined thread's runs once the world is disposed", async () => { + const { world, remote } = joined(); + await remote.run(idle); + await world.dispose(); + await expect(remote.run(idle)).rejects.toThrow("The world is disposed."); + await remote.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts new file mode 100644 index 000000000..249a7573c --- /dev/null +++ b/packages/ecs/src/implementation/world/join.ts @@ -0,0 +1,676 @@ +import type { Thread } from '../../interface/thread/thread.js'; +import type { World } from '../../interface/world/world.js'; +import { declarationOf, identifiedItems, idOfItem, idOfKey, indexOfLookup, keyOfId, labelOf, labelOfItem, requireDefinition, threadOf, threadOfDeclaration, watchedBy, type Definition } from '../../interface/registry/index.js'; +import { unsharedKeyOf } from './index-keys.js'; +import { layoutOf, type Views } from '../../interface/registry/layout.js'; +import { + completedWord, done, fitsInline, inputByMessage, inputLayoutOf, inputMoved, noInput, pending, platformLocks, postedWord, protocolName, runSlots, signalWord, unposted, unreadWord, + slotInput, slotNumber, slotPayload, slotRun, slotStatus, slotViews, slotWords, slotsWord, threadChannel, type ObserverDeclaration, type ObserverIndex, type ObserverLookup, type ThreadChannel, type ToJoined, type ToPeer, type ToWorld, +} from './joining.js'; +import { currentBuffer, sharedMemoryAvailable } from './memory.js'; +import { logRunWord } from './move-log.js'; +import { createMirror, nameComponent, rebuild, replay, type Mirror } from './mirror.js'; +import { defaultOf } from './state.js'; +import { createThreadSystems, type SystemSetup } from './thread-systems.js'; +import { taskBlockWords } from './thread-tasks.js'; +import { createThreadDirectory } from './thread-directory.js'; +import { createThreadObserver, settledFor, type Local } from './thread-observer.js'; +import type { Leaf } from '../../interface/system/leaf.js'; +import { isObserver, scheduleOf } from '../../interface/registry/schedule.js'; +import { releaseInput, transferablesOf } from './transfers.js'; + +/** The last of the numbers of runs of several observer systems. */ +const lastOf = (runs: readonly number[]): number => Math.max(...runs); + +/** An observer system's leaves: itself, or a composite's members when they're all observer; null for any other system. */ +const observerLeavesOf = (definition: Definition): readonly Leaf[] | null => { + if (isObserver(definition)) return [definition]; + if (definition.kind !== "composite") return null; + const schedule = scheduleOf(definition); + return schedule.tiers.length === 0 && schedule.observer.length > 0 ? schedule.observer : null; +}; + +/** A run not yet settled: how to settle it, and its rejection once posted. */ +type Run = { readonly resolve: (run: number) => void; readonly reject: (error: unknown) => void; message: ToJoined | null; error: unknown; readonly local: Local | null }; + +/** A run waiting for a free slot, or for the thread to have joined. */ +type Waiting = { readonly sequence: number; readonly number: number; readonly definition: Definition; readonly input: unknown }; + +const hasWaitAsync = (): boolean => typeof Atomics.waitAsync === "function"; + +/** How long a joined thread spins for the world's next task after finishing one: about one wake-up's latency. */ +const taskSpinMilliseconds = 0.02; + +/** The most a joined thread works without returning to its event loop, where it can't be cut short: a task. */ +const busyMilliseconds = 2; + +/** A turn's timeout in node: as short as it can be while still resolving as a task. */ +const turnMilliseconds = 0.001; + +/** + * Resolves on a later turn of this thread's event loop, once what was due there ran, timers included, at about a + * microsecond's cost: in a browser a message to itself, a task as a timer's is; in node, whose messages a turn can + * deliver without running timers, a wait on a word of its own that times out at once, which resolves as a task, where + * a browser's would take at least a millisecond. + */ +const createTurn = (): (() => Promise) => { + if (globalThis.process?.versions?.node !== undefined) { + const word = new Int32Array(new SharedArrayBuffer(4)); + return async () => { + const waited = Atomics.waitAsync(word, 0, 0, turnMilliseconds); + if (waited.async) await waited.value; + }; + } + const { port1, port2 } = new MessageChannel(); + const waiting: (() => void)[] = []; + port1.onmessage = () => waiting.shift()?.(); + return () => new Promise(resolve => { + waiting.push(resolve); + port2.postMessage(null); + }); +}; + +/** Whether a key's values are objects of one of `claims`, which this thread fills. */ +const ownObject = (key: symbol, claims: ReadonlySet): boolean => { + const declaration = declarationOf(key); + const thread = declaration === undefined ? null : threadOfDeclaration(declaration); + return thread !== null && claims.has(thread); +}; + +/** A key, which is a symbol. */ +const symbolOf = (key: unknown): symbol => { + if (typeof key !== "symbol") throw new Error(`"${labelOf(key)}" isn't a key: a key is a symbol.`); + return key; +}; + +/** Lets a node `MessagePort` close without keeping its thread alive: another joined thread's channel is no reason to. */ +const unref = (port: object): void => { + const method: unknown = Reflect.get(port, "unref"); + if (typeof method === "function") Reflect.apply(method, port, []); +}; + +/** The logical thread a system runs on when a joined thread claims it, by its objects; null for one that runs on `world`. */ +const placements = new WeakMap(); +const placementOf = (definition: Definition): string | null => { + let thread = placements.get(definition); + if (thread !== undefined) return thread; + thread = null; + // Render systems and GPU kernels run with the world's device; a composite's members, wherever the world runs them. + if (definition.kind === "system" && definition.declaration.kernel !== "gpu") { + try { + thread = threadOf(definition.declaration, "System", definition.name); + } + catch { + thread = null; + } + } + placements.set(definition, thread); + return thread; +}; + +/** For tests: how many joined threads a joined world has a channel to, to move runs' inputs through. */ +export const peersOf = new WeakMap number>(); + +/** For tests of what a thread holds: how many of the systems the world placed on a joined thread it keeps. */ +export const tablesOf = new WeakMap { readonly systems: number; readonly observer: number }>(); + +/** + * Joins, through `port`, the world another thread shared through the other end, once this thread has imported the + * modules of the systems it runs and the keys they name, claiming `threads`, the logical threads it fills, the world's + * own aside; none by default. Runs work as on the world's thread, by the ids the build gives top-level systems and + * keys: a run of a system without one, or one the world never imported, rejects. `add` and `remove` take observer + * systems, which run here after the world's sync points, once for the newest when this thread fell behind. `dispose` + * disconnects this thread. Throws where memory can't be shared, as without cross-origin isolation. + * + * Runs go through this thread's slots in shared memory and complete through its completed word, waiting on its signal + * word without blocking while any run, placed system or observer system is active. + */ +export const joinWorld = (port: Parameters[0], options: { readonly threads?: readonly Exclude[] } = {}): World => { + if (!sharedMemoryAvailable()) throw new Error("Without cross-origin isolation memory can't be shared, so no thread can join."); + const channel = threadChannel(port); + /** Messages posted before the join message is, which waits for this thread's lock. */ + let held: ToWorld[] | null = []; + const post = (message: ToWorld, transfer?: readonly object[]): void => { + if (held === null) channel.post(message, transfer); + else held.push(message); + }; + + /** This thread's number, as the world gave it. */ + let own = 0; + /** Channels to other joined threads, by number, with the threads each claims; and those threads' joined threads. */ + const peers = new Map; readonly threads: readonly string[] }>(); + const fillers = new Map(); + /** Inputs moved here, by the number of the joined thread whose run they're of, then by that run's sequence. */ + const received = new Map>(); + const keep = (from: number, sequence: number, input: unknown): void => { + let runs = received.get(from); + if (runs === undefined) received.set(from, runs = new Map()); + runs.set(sequence, input); + }; + /** Takes a moved input, once: undefined once a task took it. */ + const take = (from: number, sequence: number): unknown => { + const runs = received.get(from); + const input = runs?.get(sequence); + runs?.delete(sequence); + return input; + }; + /** Where a run's moved input goes: the joined thread, this one included, filling its system's thread; null for the world's. */ + const destinationOf = (definition: Definition): number | null => { + const thread = placementOf(definition); + if (thread === null) return null; + if (claims.has(thread)) return own; + return fillers.get(thread) ?? null; + }; + const unpeer = (thread: number): void => { + const peer = peers.get(thread); + if (peer === undefined) return; + peers.delete(thread); + peer.channel.close(); + for (const name of peer.threads) if (fillers.get(name) === thread) fillers.delete(name); + }; + + /** This thread's numbers, by id, for the keys and systems it names; each id is posted once. */ + const numbers = new Map(); + const numberOf = (id: string, item: boolean): number => { + const known = numbers.get(id); + if (known !== undefined) return known; + const number = numbers.size; + numbers.set(id, number); + post({ protocol: protocolName, kind: "name", number, id, item }); + return number; + }; + const keyNumber = (key: unknown): number => { + const symbol = symbolOf(key); + const id = idOfKey(symbol); + if (id === undefined) throw new Error(`"${labelOf(symbol)}" has no id, so another thread can't name it; the build gives top-level keys one.`); + return numberOf(id, false); + }; + + const claims: ReadonlySet = new Set(options.threads ?? []); + /** This thread's objects, following the world's rows, once it joined with claims; components named before it. */ + let mirror: Mirror | null = null; + const named = new Map(); + /** The systems the world places on this thread, once it joined with claims; setups that came before. */ + let systems: ReturnType | null = null; + /** Observer systems placed on this thread, once it joined. */ + let observer: ReturnType | null = null; + const setups: SystemSetup[] = []; + const finalizerSetups: SystemSetup[] = []; + + let memory: WebAssembly.Memory | null = null; + let control: Int32Array | null = null; + let views: readonly Views[] = []; + let worldWaits = true; + /** Why runs reject: the world refused this thread, was disposed, or this thread left. */ + let failure: Error | null = null; + + let sequence = 0; + /** Runs settled, in order, through the completed word. */ + let settled = 0; + let completed = 0; + const runs = new Map(); + const waiting: Waiting[] = []; + let last: Promise = Promise.resolve(); + let left = false; + + const write = ({ sequence, number, definition, input }: Waiting): void => { + if (control === null) return; + const slot = (sequence - 1) % runSlots; + const base = slotsWord + slot * slotWords; + control[base + slotStatus] = pending; + control[base + slotNumber] = number; + const schema = definition.kind === "composite" ? undefined : definition.declaration.input; + const layout = fitsInline(schema, input) ? inputLayoutOf(definition, schema) : null; + if (layout !== null && layout.kind === "linear") { + layout.write(views[slot], 0, input); + control[base + slotInput] = layout.stride; + } + else if (input === undefined) control[base + slotInput] = noInput; + else { + try { + // Transferable values move to the thread the system runs on: through the world's, or straight to the + // joined thread filling it, which tells the world once they're there. + const transfer = transferablesOf(input); + const destination = transfer === null ? null : destinationOf(definition); + if (destination === null) { + post({ protocol: protocolName, kind: "input", sequence, input }, transfer ?? undefined); + control[base + slotInput] = inputByMessage; + } + else { + if (destination === own) keep(own, sequence, input); + else peers.get(destination)?.channel.post({ protocol: protocolName, kind: "transfer", sequence, input }, transfer ?? undefined); + control[base + slotPayload] = destination; + control[base + slotInput] = inputMoved; + } + } + catch (error) { + // An input that can't be posted, as one holding a function: the world completes the run without running + // it. + const run = runs.get(sequence); + if (run !== undefined) run.error = error; + control[base + slotNumber] = unposted; + control[base + slotInput] = noInput; + } + } + Atomics.store(control, postedWord, sequence); + Atomics.notify(control, postedWord); + if (!worldWaits) post({ protocol: protocolName, kind: "wake" }); + }; + + /** Writes waiting runs while their slots are free, in order. */ + const writeWaiting = (): void => { + let written = 0; + while (written < waiting.length && control !== null && waiting[written].sequence - completed <= runSlots) write(waiting[written++]); + if (written > 0) waiting.splice(0, written); + }; + + /** Settles runs the world completed, in order: a done one at once, a rejected one once its rejection arrived. */ + const settle = (): void => { + while (settled < completed && control !== null) { + const next = settled + 1; + const run = runs.get(next); + const base = slotsWord + ((next - 1) % runSlots) * slotWords; + const status = control[base + slotStatus]; + if (run !== undefined && status !== done && run.message === null) return; + // A run of this thread's own observer system settles once a post for it ran here, unless it was removed + // since; what it threw goes to the world, for its next report. + if (run !== undefined && run.local !== null && !settledFor(run.local, next)) return; + settled = next; + runs.delete(next); + if (run === undefined) continue; + const { message } = run; + if (run.error !== undefined) run.reject(run.error); + else if (status === done) run.resolve(control[base + slotRun]); + else if (message !== null && message.kind === "rejected") run.reject(new Error(message.message)); + } + }; + + /** Names a component whose objects this thread fills, once its mirror exists. */ + const name = (component: number, key: symbol | null): void => { + if (mirror === null) { + named.set(component, key); + return; + } + nameComponent(mirror, component, key, defaultOf); + }; + + /** Whether the last `process` finished a task, so the next of a tick's tasks may follow it closely. */ + let tasked = false; + const process = (): void => { + if (control === null) return; + Atomics.store(control, unreadWord, 0); + // A task first: the world waits for it. Then finalizers of stops no task replayed. + tasked = systems?.process() ?? false; + systems?.finalize(); + if (observer !== null && observer.process() > 0) observerRan = true; + completed = Atomics.load(control, completedWord); + settle(); + writeWaiting(); + }; + + let listening = false; + const active = (): boolean => control !== null && failure === null && (runs.size > 0 || systems !== null || observer?.active() === true); + let turn: (() => Promise) | null = null; + /** The runs the world had finished when this thread last returned to its event loop, and when it did. */ + let turnedRun = 0; + let turnedAt = 0; + const runNow = (): number => mirror === null ? turnedRun : Atomics.load(mirror.header, logRunWord); + /** Whether observer systems ran since this thread last returned to its event loop: each post follows a sync point. */ + let observerRan = false; + const turned = (): void => { + turnedRun = runNow(); + turnedAt = performance.now(); + observerRan = false; + }; + /** + * Whether to return to the event loop before going on: once a run's or tick's tiers finished since it last did, + * which is after the tick's last task here, or after `busyMilliseconds`. + */ + const due = (): boolean => observerRan || runNow() !== turnedRun || performance.now() - turnedAt > busyMilliseconds; + const nextTurn = async (): Promise => { + turn ??= createTurn(); + await turn(); + turned(); + }; + /** + * Waits on the signal word without blocking while a run or system is active, so a quiet thread does no work. It + * returns to its event loop at least once a tick, so its timers, rendering and promises' callbacks run. + */ + const listen = async (): Promise => { + if (listening || !hasWaitAsync()) return; + listening = true; + turned(); + try { + while (active() && control !== null) { + // Read before what's new, so a wake after it isn't missed. + Atomics.store(control, unreadWord, 0); + const seen = Atomics.load(control, signalWord); + process(); + if (!active()) break; + // A tick's next task follows its last closely: spin briefly before paying for a wait's wake-up, with a + // turn of the event loop once the tick finished, as the world starts the next. + if (tasked) { + let deadline = performance.now() + taskSpinMilliseconds; + while (Atomics.load(control, signalWord) === seen && performance.now() < deadline) { + if (!due()) continue; + await nextTurn(); + deadline = performance.now() + taskSpinMilliseconds; + } + } + // A wake since this thread cleared the word may have notified no one, and the next wake won't notify + // while the word says this thread hasn't read it, so it reads again rather than wait. + if (Atomics.load(control, unreadWord) !== 0) { + if (due()) await nextTurn(); + continue; + } + const waited = Atomics.waitAsync(control, signalWord, seen); + if (waited.async) { + await waited.value; + turned(); + } + else if (due()) await nextTurn(); + } + } + finally { + listening = false; + } + }; + + /** Rejects every run not yet settled, and every later one; releases inputs moved here, and closes channels to other threads. */ + const fail = (error: Error): void => { + failure ??= error; + for (const run of runs.values()) run.reject(failure); + runs.clear(); + waiting.length = 0; + for (const inputs of received.values()) for (const input of inputs.values()) releaseInput(input); + received.clear(); + for (const thread of [...peers.keys()]) unpeer(thread); + if (control !== null) Atomics.notify(control, signalWord); + }; + + channel.onMessage(message => { + switch (message.kind) { + case "joined": { + memory = message.memory; + own = message.thread; + const buffer = currentBuffer(memory); + control = new Int32Array(buffer, message.control, (slotsWord + runSlots * slotWords)); + views = slotViews(buffer, message.control); + worldWaits = message.waits; + if (message.log !== null) { + mirror = createMirror(memory, message.log, message.thread); + for (const [component, key] of named) name(component, key); + named.clear(); + } + const directory = createThreadDirectory(memory, mirror); + const replayed = mirror; + observer = createThreadObserver({ + directory, mirror, post: sent => channel.post(sent), localOf: number => localsByNumber.get(number)?.deref(), + current: () => { + if (replayed === null) return true; + const state = replay(replayed); + return state !== "held" && (state !== "lapped" || rebuild(replayed)); + }, + }); + if (message.tasks !== null && mirror !== null) { + systems = createThreadSystems({ + memory, mirror, directory, control: new Int32Array(buffer, message.tasks, taskBlockWords), claims, worldWaits: message.waits, + post: (sent, transfer) => channel.post(sent, transfer), keyNumber, numbered: () => numbers.size, + }); + systems.setup(setups); + systems.setupFinalizers(finalizerSetups); + setups.length = finalizerSetups.length = 0; + } + writeWaiting(); + void listen(); + return; + } + case "refused": return fail(new Error(message.message)); + case "disposed": { + // Its finalizers release what this thread holds, as the world's do. + systems?.disposed(); + return fail(new Error("The world is disposed.")); + } + case "rejected": { + const run = runs.get(message.sequence); + if (run !== undefined) run.message = message; + return settle(); + } + case "component": { + // Its key here, if this thread has one, for a component of a thread it claims. + const key = message.id === null ? undefined : keyOfId(message.id); + name(message.component, key !== undefined && ownObject(key, claims) ? key : null); + // A task waiting for this component's key runs now, and posts of observer systems. + systems?.process(); + observer?.process(); + return; + } + case "systems": { + if (systems === null) setups.push(...message.systems); + else systems.setup(message.systems); + return systems?.process(); + } + case "unplace": { + if (systems !== null) return systems.unplace(message.indexes, message.sequence); + // Never set up, so nothing ran them. + for (let k = setups.length - 1; k >= 0; k--) if (message.indexes.includes(setups[k].index)) setups.splice(k, 1); + return post({ protocol: protocolName, kind: "unplaced", indexes: message.indexes }); + } + case "finalizers": { + if (systems === null) finalizerSetups.push(...message.finalizers); + else systems.setupFinalizers(message.finalizers); + // What waited for them goes on: stops, a task, observer systems. + systems?.finalize(); + systems?.process(); + observer?.process(); + return; + } + case "observer": { + observer?.setup(message.systems); + process(); + void listen(); + return; + } + case "unplace observers": return observer?.unplace(message.indexes); + case "task input": return systems?.input(message.sequence, message.held === undefined ? message.input : take(message.held[0], message.held[1])); + case "release": return releaseInput(take(message.from, message.sequence)); + case "peer": { + const from = message.thread; + unpeer(from); + const peer = threadChannel(message.port); + peers.set(from, { channel: peer, threads: message.threads }); + for (const name of message.threads) fillers.set(name, from); + peer.onMessage(sent => { + if (sent.kind !== "transfer") return; + if (failure !== null) return releaseInput(sent.input); + keep(from, sent.sequence, sent.input); + post({ protocol: protocolName, kind: "received", from, sequence: sent.sequence }); + }); + unref(message.port); + return; + } + case "unpeer": return unpeer(message.thread); + case "wake": return process(); + } + }); + // Held while this thread lives, so the world hears of its end however it ends: a browser worker ended from outside + // its port reports nothing else. + const locks = platformLocks(); + let release: (() => void) | null = null; + const joinMessage = (lock: string | null): ToWorld => ({ + protocol: protocolName, kind: "join", threads: options.threads ?? [], waits: hasWaitAsync(), lock, + imported: claims.size === 0 ? [] : [...identifiedItems("system"), ...identifiedItems("finalizer")], + }); + const sendHeld = (lock: string | null): void => { + const messages = held ?? []; + held = null; + channel.post(joinMessage(lock)); + for (const message of messages) channel.post(message); + }; + if (locks === null) sendHeld(null); + else { + const lock = `${protocolName}#${globalThis.crypto?.randomUUID?.() ?? String(Math.random()).slice(2)}`; + void locks.request(lock, () => new Promise(done => { + release = done; + sendHeld(lock); + })); + } + + /** This thread's own observer systems, by value and by its number for them, while they live; those added, by the value added. */ + const localsByLeaf = new WeakMap(); + const localsByNumber = new Map>(); + const added = new Map(); + const addedCounts = new Map(); + /** Tells the world an observer system of this thread's own is collected, so it forgets it. */ + const collected = new FinalizationRegistry(number => { + localsByNumber.delete(number); + if (failure === null) post({ protocol: protocolName, kind: "observer drop", number }); + }); + + /** What an observer system reads, for the world, by this thread's numbers; throws for what can't cross. */ + const describe = (leaf: Leaf): ObserverDeclaration => { + const label = labelOfItem(leaf); + const readable = (keys: readonly unknown[] | undefined): number[] => (keys ?? []).map(key => { + const symbol = symbolOf(key); + if (layoutOf(declarationOf(symbol)?.type).kind === "object" && !ownObject(symbol, claims)) { + throw new Error(`Observer system "${label}" reads "${labelOf(symbol)}", an object, which lives on another thread than this joined one.`); + } + return keyNumber(symbol); + }); + const present = (keys: readonly unknown[] | undefined): number[] => (keys ?? []).map(keyNumber); + const { components, resources, lookup } = leaf.declaration; + const lookups: ObserverLookup[] = []; + for (const name of Object.keys(lookup ?? {})) { + const declared = lookup?.[name]; + if (declared === undefined) continue; + const index = indexOfLookup(declared); + const read = readable(declared.read); + let indexed: string | ObserverIndex | null = null; + if (index !== undefined) { + const object = unsharedKeyOf(index.declaration.key); + if (object !== undefined) throw new Error(`Observer system "${label}" looks up through index "${labelOfItem(index)}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`); + if (declared.changed === true) throw new Error(`Observer system "${label}" runs when index "${labelOfItem(index)}" changed, as its lookup "${name}"'s changed is true, so it runs only on the world's thread.`); + const { key, components: held, sorted, unique } = index.declaration; + // One without an id, made in a function, is named by its declaration, and the world makes one like it. + indexed = idOfItem(index) ?? { + name: labelOfItem(index), key: readable(key), has: present(held?.has), not: present(held?.not), sorted: sorted === true, unique: unique === true, + }; + } + lookups.push({ + name, read, has: present("has" in declared ? declared.has : undefined), not: present("not" in declared ? declared.not : undefined), + changed: "changed" in declared && declared.changed !== undefined ? readable(watchedBy(declared)) : null, index: indexed, + }); + } + return { + components: components === undefined ? null : { read: readable(components.read), has: present(components.has), not: present(components.not), added: present(components.added), changed: readable(components.changed) }, + resources: resources === undefined ? null : { read: readable(resources.read), changed: present(resources.changed), is: readable(resources.is), not: readable(resources.not) }, + lookup: lookups, + }; + }; + + /** An observer system of this thread's own, declared to the world the first time; throws for one that can't cross. */ + const localOf = (leaf: Leaf): Local => { + const known = localsByLeaf.get(leaf); + if (known !== undefined) return known; + const declaration = describe(leaf); + // A number of this thread's, never posted as a name: the declaration names it. + const number = numbers.size; + numbers.set(`#observer:${number}`, number); + const local: Local = { number, leaf, runner: null, ran: 0, placed: false, stopped: 0 }; + localsByLeaf.set(leaf, local); + localsByNumber.set(number, new WeakRef(local)); + collected.register(leaf, number); + post({ protocol: protocolName, kind: "observer", number, name: labelOfItem(leaf), declaration, added: false }); + return local; + }; + + /** Posts a run of a system this thread numbers `number`; `local` for one of its own observer systems. */ + const runNumbered = (number: number, definition: Definition, input: unknown, local: Local | null): Promise => { + if (failure !== null) return Promise.reject(failure); + const ran = new Promise((resolve, reject) => { + const next = ++sequence; + runs.set(next, { resolve, reject, message: null, error: undefined, local }); + waiting.push({ sequence: next, number, definition, input }); + writeWaiting(); + }); + last = ran.catch(() => undefined); + void listen(); + return ran; + }; + + const world: World = { + run: (system, ...args) => { + const [input]: readonly unknown[] = args; + let definition: Definition; + let id: string | undefined; + try { + definition = requireDefinition(system); + // This thread's own observer systems run here, after the sync point of an empty run of the world's. + const leaves = observerLeavesOf(definition); + if (leaves !== null) { + if (failure !== null) return Promise.reject(failure); + const locals = leaves.map(localOf); + if (locals.length === 1) return runNumbered(locals[0].number, locals[0].leaf, undefined, locals[0]); + return Promise.all(locals.map(local => runNumbered(local.number, local.leaf, undefined, local))).then(lastOf); + } + id = idOfItem(definition); + if (id === undefined) throw new Error(`System "${labelOfItem(definition)}" has no id, so it can't run from another thread; the build gives top-level systems one.`); + } + catch (error) { + return Promise.reject(error); + } + if (failure !== null) return Promise.reject(failure); + return runNumbered(numberOf(id, true), definition, input, null); + }, + add: system => { + const definition = requireDefinition(system); + const leaves = observerLeavesOf(definition); + if (leaves === null) throw new Error("A joined thread adds only observer systems; others are added to ticks on the world's thread."); + if (added.has(definition) || failure !== null) return; + const locals = leaves.map(localOf); + added.set(definition, locals); + for (const local of locals) { + const count = addedCounts.get(local) ?? 0; + addedCounts.set(local, count + 1); + if (count === 0) post({ protocol: protocolName, kind: "observer add", number: local.number }); + } + }, + remove: system => { + const definition = requireDefinition(system); + if (observerLeavesOf(definition) === null) throw new Error("A joined thread removes only observer systems; others are removed from ticks on the world's thread."); + const locals = added.get(definition); + if (locals === undefined) return; + added.delete(definition); + for (const local of locals) { + const count = (addedCounts.get(local) ?? 0) - 1; + if (count > 0) { + addedCounts.set(local, count); + continue; + } + addedCounts.delete(local); + // No row of it runs from here on; the world unplaces it. + local.stopped = sequence; + observer?.stop(local); + post({ protocol: protocolName, kind: "observer remove", number: local.number }); + } + }, + share: () => { + throw new Error("Only the world's thread shares it."); + }, + dispose: async () => { + if (left) return; + left = true; + await last; + // Stops published so far are finalized here, so the world returns their indexes. + if (failure === null) systems?.finalize(); + if (failure === null) post({ protocol: protocolName, kind: "leave" }); + fail(new Error("This thread left the world.")); + channel.close(); + release?.(); + }, + [Symbol.asyncDispose]: () => world.dispose(), + }; + peersOf.set(world, () => peers.size); + tablesOf.set(world, () => ({ systems: systems?.systems() ?? 0, observer: observer?.placedCount() ?? 0 })); + return world; +}; diff --git a/packages/ecs/src/implementation/world/join.type-test.ts b/packages/ecs/src/implementation/world/join.type-test.ts new file mode 100644 index 000000000..e3c2c2734 --- /dev/null +++ b/packages/ecs/src/implementation/world/join.type-test.ts @@ -0,0 +1,27 @@ +import type { World } from '../../interface/world/world.js'; +import { System } from '../../interface/system/system.js'; +import { join } from './create.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +declare const channel: MessageChannel; +declare const worker: Worker; +declare const port: Parameters[0]; + +// Joining gives a world: runs work as on the world's thread. +const joined: World = join(channel.port2, { threads: ["main"] }); +join(worker); +join(self, {}); +join(port, { threads: [] }); +const ran: Promise = joined.run(System.create({ name: "joinTypeTest/idle" }, () => {})); +// @ts-expect-error a joined thread fills logical threads other than the world's +join(port, { threads: ["world"] }); +// @ts-expect-error it claims declared threads +join(port, { threads: ["elsewhere"] }); +// @ts-expect-error it joins through a port +join({ threads: ["main"] }); +// It joins through what the world shares through. +check[0], Parameters[0]>>(); +check, World>>(); +void [ran]; diff --git a/packages/ecs/src/implementation/world/joined-observer.ts b/packages/ecs/src/implementation/world/joined-observer.ts new file mode 100644 index 000000000..a38489448 --- /dev/null +++ b/packages/ecs/src/implementation/world/joined-observer.ts @@ -0,0 +1,809 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import type { SystemLookup } from '../../interface/system/access.js'; +import { idOfItem, idOfKey, indexOfLookup, labelOf, labelOfItem, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import type { Archetype } from './archetype.js'; +import { readCell } from './cells.js'; +import { indexOf } from './entities.js'; +import { readAhead } from './execute.js'; +import { refreshUses, type WorldIndex } from './indexes.js'; +import { writeIndexParameters } from './kernel-indexes.js'; +import { flushBetween, countChanged } from './layout-directory.js'; +import { logSyncWord, type MoveLog } from './move-log.js'; +import { lineWords } from './protocol.js'; +import type { ToJoined } from './joining.js'; +import { entriesOnJoined, keysOf, liftColumns } from './remote-systems.js'; +import { createRunner, type Runner } from './runner.js'; +import { idOf, type Key, type State } from './state.js'; +import { + doneWord, endWord, headExecuted, headRan, progressWord, snapshotCapacityWord, snapshotFlagsWord, snapshotLayout, snapshotOffsetWord, snapshotResourceArchetypeWord, + snapshotResourceRowWord, snapshotRowsWord, snapshotStartWord, snapshotted, tailBlockWords, waitingWord, type SnapshotLayout, +} from './tail-blocks.js'; +import { copyRest, linearOf, waitDone, type Copied } from './tails.js'; +import { recordWordsOf } from './thread-indexes.js'; +import type { Pool } from './pool.js'; +import { chunkShift } from './kernel.js'; + +// Observer systems on joined threads (threads.md, "Observer systems on joined threads"): after each sync point the +// world checks each one's conditions and filters and posts those that may run into their blocks, waking each thread +// once. The thread claims a post, reads the shared memory as of that sync point, and runs the system there. A writer of +// what a post still reads first copies the rest of its rows and resources, as for tails, or, for what its lookups read, +// waits for it to finish once it started and cancels it before; each sync point waits for a post in progress and +// cancels the others, which the next post replaces, so a thread that fell behind runs once, for the newest. A system +// reading only struct resources posts copies of them, so neither writers nor sync points touch its posts: the next post +// cancels one no claim reached, and is written into a second block while one runs, so the world never waits for it. + +/** An observer block: a tail block (`tail-blocks.ts`), then a line of its own. */ +const observerLine = tailBlockWords; +/** Posts so far, which the world adds to last as it posts; a thread runs a post once. */ +export const postedWord = observerLine; +/** What the thread did with the post, stored before its done word: not run, run skipped, or run taking its tick. */ +export const outcomeWord = observerLine + 1; +export const notRun = 0; +export const ranSkipped = 1; +export const ranExecuted = 2; +/** Set once the world removed the system, which the thread checks before it claims a post. */ +export const removedWord = observerLine + 2; +/** The newest run of the joined thread the post is for, its sequence, or 0 for none. */ +export const runsWord = observerLine + 3; +/** The sync of the thread's log the post is as of, which its objects are replayed through first. */ +export const syncWord = observerLine + 4; +/** From this f64 word of the block: the tick the post takes as its `lastRun` once it ran, then the `lastRun` it filters by. */ +export const tickSlot = (observerLine + 8) / 2; +export const observerBlockWords = tailBlockWords + lineWords; +const observerBlockBytes = observerBlockWords * 4; +/** What the progress word holds once the world cancelled a post no claim reached: claims add to it and stay past it. */ +export const cancelled = 0x60000000; +/** + * What the progress word holds while the world writes a post: a thread that saw the last post's posted word and claims + * now claims nothing, and claims again once the post is open, after its posted word moved. + */ +export const opening = 0x70000000; + +/** A joined thread's end for observer systems: how its setups reach it, how it's woken, and where they're placed. */ +export type ObserverTarget = { + /** The joined thread's number and the threads it claims, for labels. */ + readonly thread: number; + readonly label: string; + readonly post: (message: ToJoined) => void; + /** Wakes the thread once, after this turn's posts. */ + readonly wake: () => void; + /** Its log, once it fills objects: a post names the sync its objects are replayed through. */ + log: MoveLog | null; + /** Once gone: its posts settle, and nothing more is posted. */ + ended: boolean; + left: boolean; + readonly end: Promise; + readonly finish: () => void; + /** By index there, what's placed; indexes it dropped, placed again first. */ + readonly placed: (JoinedObserver | undefined)[]; + readonly free: number[]; +}; + +/** A lookup through an index by an observer system on a joined thread, through a record the world writes before each post. */ +type Record = { readonly index: WorldIndex; readonly watched: readonly number[]; readonly record: number; readonly words: number }; + +/** An observer system placed on a joined thread: its block, what it reads, and its post's state. */ +export type JoinedObserver = Copied & { + readonly target: ObserverTarget; + readonly index: number; + readonly label: string; + readonly runner: Runner; + /** Struct components its lookups read or watch, and the key parts of the indexes they go through. */ + readonly lookupReads: readonly number[]; + readonly watched: readonly number[]; + readonly filtered: boolean; + readonly records: readonly Record[]; + readonly ticks: Float64Array; + /** For one reading only struct resources, no rows or lookups: its posts' two places. */ + readonly contained: Contained | null; + lastRun: number; + /** While posted and not settled: the tick it takes, and whether a writer copied its reads already. */ + live: boolean; + tick: number; + copied: boolean; + /** Posts of it cancelled since it last ran: past `cancelsBeforeWaiting`, the next is waited for instead. */ + cancels: number; + /** The newest run it's owed to, which it's posted for until it ran: 0 for one of the world's, -1 for none. */ + pending: number; + stopped: boolean; + /** Whether the thread dropped it, so its block can go once no post is live. */ + dropped: boolean; + freed: boolean; +}; + +/** + * One of two places for posts of a system reading only struct resources: a block and where its snapshot is, and its + * post as written: its work's end, a new one for each post, so the thread's done word from the last needs no reset; its + * tick; and its head flags, written only when they change. + */ +type Slot = { + readonly block: number; + readonly control: Int32Array; + readonly ticks: Float64Array; + readonly snapshot: number; + /** Whether its snapshot is the block's own, so a post writes only its first and last lines. */ + readonly inline: boolean; + /** Holding a post the thread may still claim or be running. */ + live: boolean; + /** Whether its post's outcome was taken already, as the thread ran it, since it was running when the next was due. */ + ahead: boolean; + end: number; + tick: number; + flags: number; +}; + +/** + * Posts of a system reading only struct resources hold its resources' values and head flags, so the thread reads only + * its block; the post after one the thread is running goes into the other block. The thread claims and runs one post + * at a time, so the other block's post is done by then, and only the newest is ever unclaimed. + */ +type Contained = { readonly layout: SnapshotLayout; readonly slots: readonly [Slot, Slot]; current: number }; + +/** The bytes at the end of an observer block that hold a contained post's resources when they fit. */ +const inlineByte = (observerLine + 12) * 4; +const inlineBytes = observerBlockBytes - inlineByte; + +/** What the world keeps of observer systems on joined threads. */ +export type ObserverState = { + /** Posts not yet settled. */ + readonly posts: JoinedObserver[]; + /** Those owed to a run, posted after every sync point until they ran. */ + readonly pending: Set; + /** By logical thread a joined thread claims, its end. */ + readonly targets: Map; + /** A joined thread's own observer systems, as the world declares them: their thread, and its number for them. */ + readonly proxies: WeakMap; + /** How many are placed, while which directory counts are kept. */ + count: number; + /** Posts again, between runs, what's owed to a run once a post was cancelled outside a sync point. */ + again: (() => void) | null; +}; + +export const createObserverState = (): ObserverState => ({ posts: [], pending: new Set(), targets: new Map(), proxies: new WeakMap(), count: 0, again: null }); + +/** A joined thread's end. */ +export const createObserverTarget = (thread: number, label: string, post: (message: ToJoined) => void, wake: () => void): ObserverTarget => { + let finish = (): void => {}; + const end = new Promise(resolve => { finish = resolve; }); + return { thread, label, post, wake, log: null, ended: false, left: false, end, finish, placed: [], free: [] }; +}; + +const unique = (ids: readonly number[]): readonly number[] => [...new Set(ids)]; + +const lookupReadsOf = (state: State, lookup: { readonly [name: string]: SystemLookup } | undefined): { readonly reads: readonly number[]; readonly watched: readonly number[] } => { + const reads: number[] = []; + const watched: number[] = []; + for (const name in lookup) { + const declared = lookup[name]; + const index = indexOfLookup(declared); + const changed = watchedBy(declared); + for (const key of [...declared.read ?? [], ...changed, ...index?.declaration.key ?? []]) reads.push(idOf(state, key)); + if (index === undefined) for (const key of changed) watched.push(idOf(state, key)); + } + return { reads: unique(reads.filter(id => state.layouts[id].kind !== "object")), watched: unique(watched) }; +}; + +/** Starts keeping directory counts current, which a joined thread's observer systems read, if they weren't. */ +const startCounting = (state: State): void => { + if (state.counting) return; + state.counting = true; + for (const archetype of state.archetypes) countChanged(state.counted, archetype); + flushBetween(state.directory, state.bumped, state.archetypes, state.entities, state.resourceEntity, state.counted); +}; + +/** + * A runner for an observer system placed on a joined thread: the world's runner over its query, which it never starts, + * for what it matches and the indexes it looks up through; its block; and its setup posted to the thread. `since` is + * the tick it last ran at. + */ +export const createJoinedObserverRunner = (state: State, leaf: Leaf, target: ObserverTarget, since = 0): Runner => { + for (const lookup of Object.values(leaf.declaration.lookup ?? {})) { + const index = indexOfLookup(lookup); + if (index !== undefined && watchesEntries(lookup)) throw new Error(entriesOnJoined(leaf, threadOf(leaf.declaration, "System", leaf.name) ?? target.label, labelOfItem(index))); + } + // One placed there by the objects it names, rather than the thread's own, is named there by its id and its keys'. + if (!state.observer.proxies.has(leaf)) { + if (idOfItem(leaf) === undefined) { + const thread = threadOf(leaf.declaration, "System", leaf.name) ?? target.label; + throw new Error(`System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level systems one.`); + } + const unnamed = unnamedKey(leaf); + if (unnamed !== null) throw new Error(unnamed); + } + const base = createRunner(state, leaf, since); + const { declaration } = leaf; + const { components, resources } = declaration; + const ids = (keys: readonly Key[] | undefined): number[] => [...new Set((keys ?? []).map(key => idOf(state, key)))]; + // Strings live in shared memory, as structs do. + const isStruct = (id: number): boolean => state.layouts[id].kind !== "object"; + const contextKeys = [...new Set([...resources?.read ?? [], ...resources?.is ?? []])]; + const reads = linearOf(state, ids(components?.read)); + const resourceReads = linearOf(state, contextKeys.map(key => idOf(state, key))); + const looked = lookupReadsOf(state, declaration.lookup); + const block = state.memory.allocate(observerBlockBytes); + const buffer = state.memory.memory.buffer; + const control = new Int32Array(buffer, block, observerBlockWords).fill(0); + const records: Record[] = []; + const named: (readonly [string, number])[] = []; + for (const [name, index] of base.holdings.indexes?.byName ?? []) { + const declared = declaration.lookup?.[name]; + const watched = (declared === undefined ? [] : watchedBy(declared)).map(key => idOf(state, key)); + const words = recordWordsOf(index.sorted !== null, watched.length); + const record = state.memory.allocate(8 * words); + records.push({ index, watched, record, words }); + named.push([name, record]); + } + const resourceIds = ids([...resources?.read ?? [], ...resources?.is ?? [], ...resources?.not ?? [], ...resources?.changed ?? []]); + // A contained post's resources are written in its block, which nothing waits for: a string's handle there could + // outlive its record, so one reading a string posts as others do. + const layout = components === undefined && records.length === 0 && Object.keys(declaration.lookup ?? {}).length === 0 + && resourceIds.every(id => isStruct(id) && state.layouts[id].kind !== "string") + ? snapshotLayout(1, [], resourceReads.strides) + : null; + const slotAt = (at: number, slotLayout: SnapshotLayout): Slot => { + const views = state.memory.memory.buffer; + const slotControl = new Int32Array(views, at, observerBlockWords).fill(0); + // Resources that fit sit in the block's last line, with the post's other words, its snapshot laid out to end + // there. + const inline = slotLayout.bytes - slotLayout.resources[0] <= inlineBytes; + const snapshot = inline ? at + inlineByte - slotLayout.resources[0] : state.memory.allocate(slotLayout.bytes); + // What every post of it says of its snapshot but its flags. + slotControl[snapshotOffsetWord] = snapshot; + slotControl[snapshotCapacityWord] = 1; + slotControl[snapshotResourceArchetypeWord] = -1; + return { + block: at, control: slotControl, ticks: new Float64Array(views, at + tickSlot * 8, 2), snapshot, inline, + live: false, ahead: false, end: 0, tick: 0, flags: 0, + }; + }; + const contained: Contained | null = layout === null ? null : { layout, slots: [slotAt(block, layout), slotAt(state.memory.allocate(observerBlockBytes), layout)], current: 0 }; + const index = target.free.pop() ?? target.placed.length; + const proxy = state.observer.proxies.get(leaf); + const observer: JoinedObserver = { + target, index, label: proxy?.label ?? labelOfItem(leaf), runner: base, + matched: base.matched, + reads: ids([...components?.read ?? [], ...components?.added ?? [], ...components?.changed ?? []]).filter(isStruct), + resourceReads: resourceIds.filter(isStruct), + linear: reads.ids, strides: reads.strides, resources: resourceReads.ids, resourceStrides: resourceReads.strides, + writes: [], resourceObjectWrites: [], + added: ids(components?.added), changed: ids(components?.changed), resourceChanged: ids(resources?.changed), + conditionIs: ids(resources?.is), conditionNot: ids(resources?.not), + query: components !== undefined, block, control: contained?.slots[0].control ?? control, snapshot: null, end: 0, lastRun: since, + gone: () => target.ended, idle: () => target.end, + lookupReads: looked.reads, watched: looked.watched, + filtered: (components?.added?.length ?? 0) + (components?.changed?.length ?? 0) + (resources?.changed?.length ?? 0) > 0, + records, ticks: contained?.slots[0].ticks ?? new Float64Array(buffer, block + tickSlot * 8, 2), contained, + live: false, tick: 0, copied: false, cancels: 0, pending: -1, stopped: false, dropped: false, freed: false, + }; + target.placed[index] = observer; + state.observer.count++; + startCounting(state); + const keys = keysOf(leaf).map((key): readonly [number, string] => [idOf(state, key), idOfKey(key) ?? ""]); + target.post({ + protocol: "@adobe/ecs/join@1", kind: "observer", + systems: [{ index, local: proxy?.local ?? null, id: proxy === undefined ? idOfItem(leaf) ?? null : null, keys, block, spare: contained?.slots[1].block ?? null, indexes: named }], + }); + base.holdings.remote = { unplace: () => stop(state, observer) }; + return { ...base, joined: observer, lastRun: () => observer.lastRun }; +}; + +/** A contained system's posts once the thread runs none of them any more. */ +const dropPosts = (observer: JoinedObserver, contained: Contained): void => { + observer.live = false; + for (const slot of contained.slots) slot.live = false; +}; + +/** Frees a stopped system's block and records once the thread dropped it, or left, and no post of it is live. */ +const tryFree = (state: State, observer: JoinedObserver): void => { + const { contained } = observer; + if (observer.freed || !observer.stopped || observer.live || !(observer.dropped || observer.target.left)) return; + if (contained !== null && (contained.slots[0].live || contained.slots[1].live)) return; + observer.freed = true; + if (contained !== null) { + state.memory.free(contained.slots[1].block, observerBlockBytes); + for (const slot of contained.slots) if (!slot.inline) state.memory.free(slot.snapshot, contained.layout.bytes); + } + if (observer.snapshot !== null) state.memory.free(observer.snapshot.offset, observer.snapshot.bytes); + observer.snapshot = null; + state.memory.free(observer.block, observerBlockBytes); + for (const { record, words } of observer.records) state.memory.free(record, 8 * words); + const { target } = observer; + if (target.placed[observer.index] === observer) { + target.placed[observer.index] = undefined; + if (!target.ended) target.free.push(observer.index); + } +}; + +/** + * Stops an observer system on a joined thread, as it's removed: the thread runs no post of it once this returns, + * checking its removed word before it claims one; and it's told to drop it, its block freed once it did. + */ +const stop = (state: State, observer: JoinedObserver): void => { + if (observer.stopped) return; + observer.stopped = true; + Atomics.store(observer.control, removedWord, 1); + if (observer.contained !== null) Atomics.store(observer.contained.slots[1].control, removedWord, 1); + state.observer.pending.delete(observer); + observer.pending = -1; + state.observer.count--; + if (state.observer.count === 0 && !state.claimed) state.counting = false; + if (!observer.target.ended) observer.target.post({ protocol: "@adobe/ecs/join@1", kind: "unplace observers", indexes: [observer.index] }); + // Posts no sync point settles: released now, but one running, which is once the thread dropped it. + if (observer.contained !== null) { + observer.live = false; + for (const slot of observer.contained.slots) releaseSlot(observer, slot); + } + tryFree(state, observer); +}; + +/** The thread dropped what it held for systems at these indexes. */ +export const droppedObserver = (state: State, target: ObserverTarget, indexes: readonly number[]): void => { + for (const index of indexes) { + const observer = target.placed[index]; + if (observer === undefined || !observer.stopped) continue; + observer.dropped = true; + // Dropped between its posts, the thread runs none of them any more. + if (observer.contained !== null) dropPosts(observer, observer.contained); + tryFree(state, observer); + } +}; + +/** Once a joined thread is gone: its posts settle, and, once it left, its blocks are freed. */ +export const endTarget = (state: State, target: ObserverTarget, left: boolean): void => { + target.ended = true; + target.left = left; + target.finish(); + for (const observer of target.placed) { + if (observer === undefined) continue; + state.observer.pending.delete(observer); + observer.pending = -1; + if (observer.contained !== null) dropPosts(observer, observer.contained); + tryFree(state, observer); + } +}; + +/** Whether a resource holds a value, in the resource entity's archetype at its row. */ +const holdingIn = (state: State, archetype: Archetype | undefined, row: number, id: number): boolean => { + // An object here may be a joined thread's own, which only it reads: that thread checks it. + if (state.layouts[id].kind === "object") return true; + const storage = archetype?.storageById[id]; + if (storage === undefined) return false; + const held = readCell(storage, row); + return held !== null && held !== false; +}; + +/** + * Whether an observer system may run after this sync point: its conditions hold, and, with change filters, a resource + * it filters on changed or a chunk of a component it filters on in an archetype it matches was stamped since it last + * ran. + */ +const mayRun = (state: State, observer: JoinedObserver): boolean => { + const { lastRun, conditionIs, conditionNot, resourceChanged } = observer; + if (conditionIs.length + conditionNot.length + resourceChanged.length > 0) { + const index = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + const archetype = index < 0 ? undefined : state.archetypes[state.entities.archetype[index]]; + const row = index < 0 ? 0 : state.entities.row[index]; + for (let k = 0; k < conditionIs.length; k++) if (!holdingIn(state, archetype, row, conditionIs[k])) return false; + for (let k = 0; k < conditionNot.length; k++) if (holdingIn(state, archetype, row, conditionNot[k])) return false; + if (!observer.filtered) return true; + for (let k = 0; k < resourceChanged.length; k++) { + const storage = archetype?.storageById[resourceChanged[k]]; + if (storage !== undefined && storage.changed[row] > lastRun) return true; + } + } + else if (!observer.filtered) return true; + if (observer.added.length + observer.changed.length === 0) return false; + for (const archetype of observer.matched()) { + if (archetype.count === 0) continue; + const chunks = ((archetype.count - 1) >> chunkShift) + 1; + for (const id of observer.added) { + const storage = archetype.storageById[id]; + if (storage === undefined) continue; + for (let chunk = 0; chunk < chunks; chunk++) if (storage.chunkAdded[chunk] > lastRun) return true; + } + for (const id of observer.changed) { + const storage = archetype.storageById[id]; + if (storage === undefined) continue; + if (storage.columnChanged > lastRun) return true; + for (let chunk = 0; chunk < chunks; chunk++) if (storage.chunkChanged[chunk] > lastRun) return true; + } + } + return false; +}; + +/** Parameters of an index lookup, reused, and a view of the memory they're copied into. */ +const parameters: number[] = []; +let words = new Float64Array(0); +let bytes = new Uint8Array(0); + +/** Writes a post into a system's block: its work's end, its filters' `lastRun`, its tick and the run it's for. */ +const write = (state: State, observer: JoinedObserver, tick: number): void => { + if (observer.contained !== null) { + writeContained(state, observer, observer.contained, tick, headOf(state, observer)); + return; + } + const { control } = observer; + let rows = 0; + if (observer.query) for (const archetype of observer.matched()) rows += archetype.count; + observer.end = 1 + rows; + // A GPU kernel's whole-column stamps reach the thread as row ticks. + if (observer.changed.length > 0) for (const archetype of observer.matched()) liftColumns(archetype, observer.changed, observer.lastRun); + if (observer.watched.length > 0) for (const archetype of state.archetypes) liftColumns(archetype, observer.watched, observer.lastRun); + if (observer.records.length > 0) { + if (words.buffer !== state.memory.memory.buffer) words = new Float64Array(state.memory.memory.buffer); + for (const { index, watched, record } of observer.records) { + const count = writeIndexParameters({ index, watched }, observer.lastRun, parameters, 0); + for (let k = 0; k < count; k++) words[record / 8 + k] = parameters[k]; + } + } + // Closed first, so no claim reaches it before every word of it is written. + Atomics.store(control, progressWord, opening); + for (const word of [doneWord, waitingWord, snapshotOffsetWord, snapshotRowsWord, snapshotStartWord, snapshotFlagsWord, outcomeWord]) Atomics.store(control, word, 0); + Atomics.store(control, endWord, observer.end); + Atomics.store(control, runsWord, Math.max(observer.pending, 0)); + Atomics.store(control, syncWord, observer.target.log === null ? 0 : Atomics.load(observer.target.log.header, logSyncWord)); + observer.ticks[0] = tick; + observer.ticks[1] = observer.lastRun; + observer.tick = tick; + observer.copied = false; + observer.live = true; + state.observer.posts.push(observer); + // A thread that sees it sees the rest; then it opens, so a claim of it finds its posted word moved. + Atomics.add(control, postedWord, 1); + Atomics.store(control, progressWord, 0); + observer.target.wake(); +}; + +/** + * Writes a post of one reading only struct resources into its idle block: its head's flags as of this sync point and + * its resources' values, so the thread reads only the block, which opens as copied. Touches the block's first and last + * lines, and its snapshot's line when its flags change or its resources don't fit the last. + */ +const writeContained = (state: State, observer: JoinedObserver, contained: Contained, tick: number, flags: number): void => { + const slot = contained.slots[contained.current]; + const { control, snapshot } = slot; + const end = ++slot.end; + Atomics.store(control, progressWord, opening); + control[endWord] = end; + if ((flags & headRan) !== 0 && state.resourceEntity >= 0) { + const index = indexOf(state.resourceEntity); + const archetype = state.archetypes[state.entities.archetype[index]]; + const row = state.entities.row[index]; + if (bytes.buffer !== state.memory.memory.buffer) bytes = new Uint8Array(state.memory.memory.buffer); + for (let k = 0; k < observer.resources.length; k++) { + const storage = archetype?.storageById[observer.resources[k]]; + if (storage === undefined) continue; + const stride = observer.resourceStrides[k]; + const from = storage.views.u8; + const source = row * stride; + const target = snapshot + contained.layout.resources[k]; + for (let b = 0; b < stride; b++) bytes[target + b] = from[source + b]; + } + } + if (flags !== slot.flags) control[snapshotFlagsWord] = flags; + control[outcomeWord] = notRun; + control[runsWord] = observer.pending < 0 ? 0 : observer.pending; + slot.ticks[0] = tick; + slot.ticks[1] = observer.lastRun; + slot.tick = tick; + slot.flags = flags; + slot.live = true; + slot.ahead = false; + observer.tick = tick; + observer.live = true; + // As for any post, a thread that sees its posted word sees the rest, and claims it once it's open. + Atomics.add(control, postedWord, 1); + Atomics.store(control, progressWord, snapshotted); + observer.target.wake(); +}; + +/** A contained post's head flags as of this sync point: whether its conditions hold, and its filter passes. */ +const headOf = (state: State, observer: JoinedObserver): number => { + const index = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + const archetype = index < 0 ? undefined : state.archetypes[state.entities.archetype[index]]; + const row = index < 0 ? 0 : state.entities.row[index]; + for (let k = 0; k < observer.conditionIs.length; k++) if (!holdingIn(state, archetype, row, observer.conditionIs[k])) return 0; + for (let k = 0; k < observer.conditionNot.length; k++) if (holdingIn(state, archetype, row, observer.conditionNot[k])) return 0; + if (!observer.filtered) return headPasses; + for (let k = 0; k < observer.resourceChanged.length; k++) { + const storage = archetype?.storageById[observer.resourceChanged[k]]; + if (storage !== undefined && storage.changed[row] > observer.lastRun) return headPasses; + } + return headExecuted; +}; + +/** Head flags of a post whose conditions hold and whose filter passes, so its body runs. */ +const headPasses = headExecuted | headRan; + +/** Takes what the thread did with a contained post, or will do with one it's running: the tick it took, and the run it was for. */ +const takeOutcome = (observer: JoinedObserver, slot: Slot, outcome: number): void => { + if (outcome !== notRun) observer.cancels = 0; + if (outcome === ranExecuted && slot.tick > observer.lastRun) observer.lastRun = slot.tick; + if (outcome !== notRun && observer.pending >= 0 && slot.control[runsWord] >= observer.pending) observer.pending = -1; +}; + +/** + * Releases a contained post: one no claim reached, cancelled, or one the thread ran, its outcome taken unless it was + * already. The thread stores what it did as it finishes running, before its done word, and reads nothing of the post + * after, so a post that says what it did is done. One the thread is running stays live, its outcome taken as its head + * flags say, which is what the thread does with it: false then. + */ +const releaseSlot = (observer: JoinedObserver, slot: Slot): boolean => { + if (!slot.live) return true; + const { control } = slot; + if (Atomics.compareExchange(control, progressWord, snapshotted, cancelled) === snapshotted) { + slot.live = false; + return true; + } + const outcome = Atomics.load(control, outcomeWord); + if (outcome !== notRun || Atomics.load(control, doneWord) >= slot.end) { + slot.live = false; + if (!slot.ahead) takeOutcome(observer, slot, outcome); + return true; + } + if (!slot.ahead) { + slot.ahead = true; + takeOutcome(observer, slot, (slot.flags & headExecuted) !== 0 ? ranExecuted : ranSkipped); + } + return false; +}; + +/** + * Before a contained system's next post: releases its live post and readies an idle block, the other one when the + * thread is running that post; a promise only when both are in use, which the thread's running one post at a time + * leaves only for a thread that's gone. + */ +const release = (observer: JoinedObserver, contained: Contained): Promise | null => { + observer.live = false; + if (releaseSlot(observer, contained.slots[contained.current])) return null; + contained.current = 1 - contained.current; + const other = contained.slots[contained.current]; + if (releaseSlot(observer, other)) return null; + return waitDone({ control: other.control, gone: observer.gone, idle: observer.idle }, other.end).then(() => { + other.control[waitingWord] = 0; + releaseSlot(observer, other); + }); +}; + +const noObserver: readonly JoinedObserver[] = []; + +/** + * After a sync point: posts the observer systems on joined threads among those of the run, `own`, which are owed to run + * `run` (0 for one of the world's), those added, `added`, that may run, and those still owed to a run. A contained post + * still live is settled first, waiting only for one in progress. Reads back what the GPU wrote that they read, or + * their indexes hold by, then refreshes those indexes, first; a promise only when that waits. + */ +export const postObserver = (state: State, own: readonly JoinedObserver[], added: readonly JoinedObserver[], run: number, pool: Pool | null): Promise | null => { + const { pending } = state.observer; + for (let k = 0; k < own.length; k++) { + const observer = own[k]; + if (observer.stopped || observer.target.ended) continue; + observer.pending = Math.max(observer.pending, run); + pending.add(observer); + } + let posting: JoinedObserver[] | null = null; + let waits: Promise[] | null = null; + // Contained posts read nothing the GPU writes or an index holds, so with nothing to read back they're written at + // once, at one tick for the pass, their heads worked out once. + const { gpu } = state; + const direct = gpu === null || !gpu.anyAhead(); + let tick = -1; + for (const observer of pending) { + const { contained } = observer; + if (observer.live) { + if (contained === null) continue; + const releasing = release(observer, contained); + // Released, it may have run for the newest run it's owed to. + if (observer.pending < 0) { + pending.delete(observer); + continue; + } + if (releasing !== null) { + waits = gather(waits, releasing); + (posting ??= []).push(observer); + continue; + } + } + if (contained !== null && direct) writeContained(state, observer, contained, tick < 0 ? tick = ++state.tick : tick, headOf(state, observer)); + else (posting ??= []).push(observer); + } + for (let k = 0; k < added.length; k++) { + const observer = added[k]; + if (observer.stopped || observer.target.ended || observer.pending >= 0) continue; + const { contained } = observer; + if (observer.live) { + if (contained === null) continue; + const releasing = release(observer, contained); + // Its blocks both in use, it's posted once one is idle, if it may run then. + if (releasing !== null) { + waits = gather(waits, releasing); + (posting ??= []).push(observer); + continue; + } + } + if (contained !== null && direct) { + const flags = headOf(state, observer); + if (flags === headPasses) writeContained(state, observer, contained, tick < 0 ? tick = ++state.tick : tick, flags); + } + else if (mayRun(state, observer)) (posting ??= []).push(observer); + } + if (posting === null) return joinAll(waits); + const chosen = posting; + if (waits === null) return readThenPost(state, chosen, pool, false); + return Promise.all(waits).then(() => readThenPost(state, chosen, pool, true) ?? undefined); +}; + +/** Posts the chosen ones; `waited`: a contained one waited for is posted only if it may still run. */ +const postChosen = (state: State, chosen: readonly JoinedObserver[], waited: boolean): void => { + const tick = ++state.tick; + for (let k = 0; k < chosen.length; k++) { + const observer = chosen[k]; + if (observer.live || observer.stopped || observer.target.ended) continue; + if (waited && observer.contained !== null && observer.pending < 0 && !mayRun(state, observer)) continue; + write(state, observer, tick); + } +}; + +/** + * Reads back what the GPU wrote that the chosen posts read, refreshes the indexes they look up through, then posts + * them: a promise only when that waits. + */ +const readThenPost = (state: State, chosen: readonly JoinedObserver[], pool: Pool | null, waited: boolean): Promise | null => { + const { gpu } = state; + // What the GPU wrote is read back first, so the indexes refresh from the values it wrote. + if (gpu !== null && gpu.anyAhead()) return readAhead(state, gpu, chosen.map(observer => observer.runner)).then(() => refreshThenPost(state, chosen, pool, waited) ?? undefined); + return refreshThenPost(state, chosen, pool, waited); +}; + +/** Refreshes the indexes the chosen posts look up through, then posts them: a promise only when refreshing waits. */ +const refreshThenPost = (state: State, chosen: readonly JoinedObserver[], pool: Pool | null, waited: boolean): Promise | null => { + let waiting: Promise[] | null = null; + for (let k = 0; k < chosen.length; k++) { + const uses = chosen[k].runner.indexes; + if (uses.length === 0) continue; + const building = refreshUses(state, uses, pool); + if (building !== null) (waiting ??= []).push(building); + } + if (waiting === null) { + postChosen(state, chosen, waited); + return null; + } + return Promise.all(waiting).then(() => postChosen(state, chosen, waited)); +}; + +/** The observer systems on joined threads among runners. */ +export const joinedOf = (runners: readonly Runner[]): readonly JoinedObserver[] => { + let found: JoinedObserver[] | null = null; + for (let k = 0; k < runners.length; k++) { + const { joined } = runners[k]; + if (joined !== null) (found ??= []).push(joined); + } + return found ?? noObserver; +}; + +/** Settles a post: what the thread did with it, the tick it took, and whether it's still owed to a run. */ +const settle = (state: State, observer: JoinedObserver): void => { + if (!observer.live) return; + observer.live = false; + const at = state.observer.posts.indexOf(observer); + if (at >= 0) state.observer.posts.splice(at, 1); + const outcome = Atomics.load(observer.control, outcomeWord); + if (outcome !== notRun) observer.cancels = 0; + if (outcome === ranExecuted) observer.lastRun = observer.tick; + if (outcome !== notRun && observer.pending >= 0 && Atomics.load(observer.control, runsWord) >= observer.pending) { + observer.pending = -1; + state.observer.pending.delete(observer); + } + tryFree(state, observer); +}; + +/** + * Posts of a system cancelled in a row before the next is waited for instead, so a thread slower to claim than the + * world is to write runs it at least once in this many sync points plus one, however fast the world ticks. + */ +export const cancelsBeforeWaiting = 4; + +/** + * Cancels a post no claim reached, settling it; false once the thread claimed it, or when it's the one after + * `cancelsBeforeWaiting` cancelled ones, which the caller waits for. + */ +const cancel = (state: State, observer: JoinedObserver): boolean => { + const { control } = observer; + if (!observer.stopped && observer.cancels >= cancelsBeforeWaiting) return false; + if (Atomics.compareExchange(control, progressWord, 0, cancelled) !== 0 && Atomics.compareExchange(control, progressWord, snapshotted, cancelled) !== snapshotted) return false; + observer.cancels++; + settle(state, observer); + return true; +}; + +/** Waits for a post in progress to finish, then settles it. */ +const finish = (state: State, observer: JoinedObserver): Promise | null => { + if (Atomics.load(observer.control, doneWord) >= observer.end) { + settle(state, observer); + return null; + } + return waitDone(observer, observer.end).then(() => settle(state, observer)); +}; + +const gather = (waits: Promise[] | null, next: Promise | null): Promise[] | null => { + if (next === null) return waits; + (waits ??= []).push(next); + return waits; +}; + +const joinAll = (waits: Promise[] | null): Promise | null => waits === null ? null : waits.length === 1 ? waits[0] : Promise.all(waits).then(() => undefined); + +/** + * Before a sync point, or what moves rows outside one: waits for every post in progress and cancels the rest, so the + * next post, for the newest sync, replaces them. `between`: outside a sync point, where what's owed to a run is posted + * again once it's done. + */ +export const settlePosts = (state: State, between = false): Promise | null => { + let waits: Promise[] | null = null; + let owed = false; + for (const observer of [...state.observer.posts]) { + if (cancel(state, observer)) { + if (observer.pending >= 0) owed = true; + continue; + } + waits = gather(waits, finish(state, observer)); + } + if (between && owed) state.observer.again?.(); + return joinAll(waits); +}; + +const any = (ids: readonly number[], among: readonly number[]): boolean => { + for (let i = 0; i < ids.length; i++) if (among.includes(ids[i])) return true; + return false; +}; + +/** + * Before a writer of components `components` and resources `resources`: each post reading what its lookups read is + * waited for once it started and cancelled before; each reading its rows or resources has the rest of them copied. + */ +const settleWrites = (state: State, components: readonly number[], resources: readonly number[]): Promise | null => { + let waits: Promise[] | null = null; + for (const observer of [...state.observer.posts]) { + if (observer.lookupReads.length > 0 && any(components, observer.lookupReads)) { + if (!cancel(state, observer)) waits = gather(waits, finish(state, observer)); + continue; + } + if (observer.copied || !(any(components, observer.reads) || any(resources, observer.resourceReads))) continue; + observer.copied = true; + waits = gather(waits, copyRest(state, observer)); + } + return joinAll(waits); +}; + +/** Before runners that write components or resources. */ +export const settleObserverRunners = (state: State, runners: readonly Runner[]): Promise | null => { + let waits: Promise[] | null = null; + for (let k = 0; k < runners.length; k++) { + const { writes, resourceWrites } = runners[k]; + if (writes.length + resourceWrites.length > 0) waits = gather(waits, settleWrites(state, writes, resourceWrites)); + } + return joinAll(waits); +}; + +/** Before the world writes these components or resources itself, as GPU readbacks and the clock do. */ +export const settleObserverKeys = (state: State, components: readonly number[], resources: readonly number[]): Promise | null => + settleWrites(state, components, resources); + +/** Why an observer system's key can't cross to a joined thread, or null when every one can. */ +export const unnamedKey = (leaf: Leaf): string | null => { + const unnamed = keysOf(leaf).find(key => idOfKey(key) === undefined); + return unnamed === undefined ? null : `System "${labelOfItem(leaf)}" names "${labelOf(unnamed)}", which has no id, so a joined thread can't name it; the build gives top-level keys one.`; +}; + +/** Runs of observer systems from joined threads, by the value standing for their input: the run's sequence there. */ +const joinedRuns = new WeakMap(); + +/** What a joined thread's run of its own observer system passes for input, naming the run. */ +export const joinedRun = (sequence: number): object => { + const run = {}; + joinedRuns.set(run, sequence); + return run; +}; + +/** The sequence of the joined run a run's input names, or 0 for one that names none. */ +export const joinedRunOf = (value: unknown): number => typeof value === "object" && value !== null ? joinedRuns.get(value) ?? 0 : 0; diff --git a/packages/ecs/src/implementation/world/joining-worker.fixture.ts b/packages/ecs/src/implementation/world/joining-worker.fixture.ts new file mode 100644 index 000000000..d78ba786e --- /dev/null +++ b/packages/ecs/src/implementation/world/joining-worker.fixture.ts @@ -0,0 +1,69 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { Observe } from "../../observe/index.js"; +import { createWorld, join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { drive, idle, names, total, until, withReports, type Driven, type Reported } from "./joining.test-helper.js"; + +/** A thread joining a world through the port it's sent and driving it, or holding a world it shares through it. */ + +/** A system only this thread imports, which a world elsewhere never did. */ +const unimportedByMain = System.create({ name: "joiningTest/unimportedByMain" }, () => {}); + +type Port = MessagePort | NodeMessagePort; +export type ToJoiningWorker = + | { readonly kind: "join"; readonly port: Port; readonly threads: readonly "main"[] } + | { readonly kind: "idle"; readonly port: Port; readonly threads: readonly "main"[] } + | { readonly kind: "host"; readonly port: Port } + /** Hold a world shared through this worker's own scope, which this fixture's messages share. */ + | { readonly kind: "host scope" } + /** Once the thread that joined has left: the world's state, and the reports of the runs it drove. */ + | { readonly kind: "state"; readonly driven?: Driven }; +export type FromJoiningWorker = + | { readonly kind: "driven"; readonly driven: Driven } + | { readonly kind: "joined"; readonly ran: string } + | { readonly kind: "shared" } + | { readonly kind: "state"; readonly driven: Reported | null; readonly total: number; readonly names: readonly string[]; readonly status: string | null }; + +const channel = scopeChannel(); +let hosted: ReturnType | null = null; +channel.onMessage(async message => { + // The world's own messages, where it shares the scope. + if ("protocol" in message) return; + if (message.kind === "host scope") { + const world = createWorld({ [workers]: 0 }); + const scope = globalThis.process?.getBuiltinModule?.("node:worker_threads")?.parentPort ?? self; + world.share(scope); + hosted = world; + channel.post({ kind: "shared" }); + } + else if (message.kind === "join") { + const remote = join(message.port, { threads: message.threads }); + channel.post({ kind: "driven", driven: await drive(remote, unimportedByMain) }); + await remote.dispose(); + } + else if (message.kind === "idle") { + // Joins and stays, its one run's outcome posted. + const remote = join(message.port, { threads: message.threads }); + const ran = await remote.run(idle).then(() => "ran", (error: unknown) => error instanceof Error ? error.message : String(error)); + channel.post({ kind: "joined", ran }); + } + else if (message.kind === "host") { + const world = createWorld({ [workers]: 0 }); + world.share(message.port); + hosted = world; + channel.post({ kind: "shared" }); + } + else if (message.kind === "state" && hosted !== null) { + // Once the thread that joined has left. + const threads = await until(hosted.threads, shown => shown[0]?.status === "gone"); + channel.post({ + kind: "state", + driven: message.driven === undefined ? null : await withReports(hosted, message.driven), + total: await Observe.toPromise(Observe.resource(hosted, total)), + names: await Observe.toPromise(Observe.resource(hosted, names)), + status: threads[0]?.status ?? null, + }); + } +}); diff --git a/packages/ecs/src/implementation/world/joining.cases.ts b/packages/ecs/src/implementation/world/joining.cases.ts new file mode 100644 index 000000000..bdf9ede05 --- /dev/null +++ b/packages/ecs/src/implementation/world/joining.cases.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./joining.fixture.js"; + +const clean = { failures: [], skipped: [], dropped: 0, warnings: [] }; + +/** Threads joining worlds on others: the cases, over the fixture built for node or from source. */ +export const joiningCases = (load: () => Promise) => describe("joining", () => { + const expectDriven = (driven: Awaited>["driven"] | null, unimported: RegExp) => { + expect(driven?.reports).toEqual([clean, clean, clean, clean, clean]); + expect(driven?.failures).toEqual(["joiningTest failure"]); + expect(driven?.total).toBe(123); + expect(driven?.selected).toBe(2); + expect(driven?.counted).toBe(1); + expect(driven?.listed).toEqual([[2, -2], true]); + expect(driven?.unimported).toMatch(unimported); + expect(driven?.unnamed).toMatch(/has no id, so it can't run from another thread/); + }; + + it("let a worker join a world here: runs land in call order, observers see the world, and its entity shows its progress", async () => { + const fixture = await load(); + const result = await fixture.worldHere(); + expectDriven(result.driven, /^System ".*joining-worker\.fixture\.ts#unimportedByMain" isn't one this world imported, so it can't run here\.$/); + expect(result.claims).toEqual(["main"]); + expect(result.status).toBe("gone"); + expect(result.total).toBe(123); + expect(result.names).toEqual(["a", "b"]); + }, 30000); + + it("let this thread join a world on a worker, by the ids of keys and systems both threads imported", async () => { + const fixture = await load(); + const result = await fixture.worldThere(); + expectDriven(result.driven, /^System ".*joining\.fixture\.ts#unimportedByWorker" isn't one this world imported, so it can't run here\.$/); + expect(result.total).toBe(123); + expect(result.names).toEqual(["a", "b"]); + expect(result.status).toBe("gone"); + }, 30000); + + it("share a world through a worker's own scope, joined through the worker, the observer factories there giving its values", async () => { + const fixture = await load(); + expect(await fixture.scopes()).toEqual({ observed: 456, total: 456, status: "gone" }); + }, 30000); + + it("show a joined thread that dies as gone", async () => { + const fixture = await load(); + expect(await fixture.dies()).toEqual({ ran: "ran", status: "gone" }); + }, 30000); + + it("refuse a thread claimed twice", async () => { + const fixture = await load(); + expect(await fixture.claimedTwice()).toEqual({ first: "ran", second: 'Thread "main" is claimed twice.', status: "gone" }); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/joining.fixture.ts b/packages/ecs/src/implementation/world/joining.fixture.ts new file mode 100644 index 000000000..50c280ba1 --- /dev/null +++ b/packages/ecs/src/implementation/world/joining.fixture.ts @@ -0,0 +1,138 @@ +/// +import { Observe } from "../../observe/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join } from "./create.js"; +import type { FromJoiningWorker, ToJoiningWorker } from "./joining-worker.fixture.js"; +import { add, drive, names, total, until, withReports } from "./joining.test-helper.js"; + +/** + * Scenarios of a thread joining a world on another, run built in node and from source in chromium: a worker joining a + * world here, this thread joining a world on a worker, a joined worker that dies, and a thread claimed twice. + */ + +/** A system only this thread imports, which a world on a worker never did. */ +const unimportedByWorker = System.create({ name: "joiningTest/unimportedByWorker" }, () => {}); + +type Worker = { + readonly ask: (message: ToJoiningWorker, transfer?: readonly unknown[]) => Promise; + readonly terminate: () => Promise; + /** The worker itself, as a port to join a world it shares through its scope. */ + readonly port: Parameters[0]; +}; + +/** Whether a reply is the fixture's, not the world's, where it shares the worker's scope. */ +const isReply = (data: unknown): boolean => typeof data === "object" && data !== null && !("protocol" in data); + +const workerFile = "./joining-worker.fixture.js"; + +/** Starts the joining worker, which answers each message with one. */ +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./joining-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer = []) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => { if (isReply(event.data)) resolve(event.data); }; + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + port: worker, + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer = []) => new Promise(resolve => { + const reply = (data: FromJoiningWorker): void => { + if (!isReply(data)) return; + worker.off("message", reply); + resolve(data); + }; + worker.on("message", reply); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + port: worker, + }; +}; + +const within = (promise: Promise, milliseconds: number, otherwise: T): Promise => + Promise.race([promise, new Promise(resolve => setTimeout(() => resolve(otherwise), milliseconds))]); + +/** A world here, a worker joining it and driving it, then leaving. */ +export const worldHere = async () => { + const world = createWorld({ [workers]: 0 }); + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const joined = until(world.threads, threads => threads[0]?.status === "joined"); + const worker = start(); + const reply = await worker.ask({ kind: "join", port: port2, threads: ["main"] }, [port2]); + const claims = (await joined)[0]?.claims ?? null; + const gone = (await until(world.threads, threads => threads[0]?.status === "gone"))[0]; + const result = { + driven: reply.kind === "driven" ? await withReports(world, reply.driven) : null, + claims, + status: gone?.status ?? null, + total: await Observe.toPromise(Observe.resource(world, total)), + names: await Observe.toPromise(Observe.resource(world, names)), + }; + await worker.terminate(); + await world.dispose(); + return result; +}; + +/** A world on a worker, this thread joining it and driving it, then leaving. */ +export const worldThere = async () => { + const worker = start(); + const { port1, port2 } = new MessageChannel(); + await worker.ask({ kind: "host", port: port1 }, [port1]); + const remote = join(port2, { threads: ["main"] }); + const driven = await drive(remote, unimportedByWorker); + await remote.dispose(); + const state = await worker.ask({ kind: "state", driven }); + await worker.terminate(); + return state.kind === "state" ? { driven: state.driven, total: state.total, names: state.names, status: state.status } : { driven: null }; +}; + +/** A worker that joined, then is ended from outside: its status, `"gone"` if the world heard of it. */ +export const dies = async () => { + const world = createWorld({ [workers]: 0 }); + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + const reply = await worker.ask({ kind: "idle", port: port2, threads: [] }, [port2]); + await worker.terminate(); + const gone = await within(until(world.threads, threads => threads[0]?.status === "gone"), 20000, null); + await world.dispose(); + return { ran: reply.kind === "joined" ? reply.ran : null, status: gone?.[0]?.status ?? null }; +}; + +/** Two workers claiming `main`: the second's run, and its status. */ +export const claimedTwice = async () => { + const world = createWorld({ [workers]: 0 }); + const first = new MessageChannel(); + const second = new MessageChannel(); + world.share(first.port1); + world.share(second.port1); + const pair = [start(), start()]; + const one = await pair[0].ask({ kind: "idle", port: first.port2, threads: ["main"] }, [first.port2]); + const two = await pair[1].ask({ kind: "idle", port: second.port2, threads: ["main"] }, [second.port2]); + const gone = (await until(world.threads, threads => threads[1]?.status === "gone"))[1]; + for (const worker of pair) await worker.terminate(); + await world.dispose(); + return { first: one.kind === "joined" ? one.ran : null, second: two.kind === "joined" ? two.ran : null, status: gone?.status ?? null }; +}; + +/** A world on a worker shared through its own scope, this thread joined through the worker: runs, an observer factory, leaving. */ +export const scopes = async () => { + const worker = start(); + await worker.ask({ kind: "host scope" }); + const remote = join(worker.port); + for (const amount of [4, 5, 6]) await remote.run(add, { amount }); + const observed = await until(Observe.resource(remote, total), value => value === 456); + await remote.dispose(); + const state = await worker.ask({ kind: "state" }); + await worker.terminate(); + return { observed, ...state.kind === "state" ? { total: state.total, status: state.status } : {} }; +}; diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts new file mode 100644 index 000000000..a6e7d026c --- /dev/null +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -0,0 +1,92 @@ +import { Component } from "../../interface/component/component.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import { Observe } from "../../observe/index.js"; +import type { World } from "../../interface/world/world.js"; +import { reportOf } from "./reports.test-helper.js"; + +// Keys and systems both threads of the joining fixtures import, and the scenario a joined thread drives. + +export const point = Symbol("joiningTest/point"); +export const label = Symbol("joiningTest/label"); +export const marked = Symbol("joiningTest/marked"); +export const total = Symbol("joiningTest/total"); +export const names = Symbol("joiningTest/names"); +declare module "@adobe/ecs" { + export interface Components { + [point]: { type: readonly [number, number] }; + [label]: { type: string }; + [marked]: { type: true }; + } + export interface Resources { + [total]: { type: number }; + [names]: { type: readonly string[] }; + } +} +Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(label, { type: { type: "string" } }); +Component.register(marked, { type: { const: true } }); +Resource.register(total, { type: { type: "number", default: 0 } }); +Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); + +/** Appends a digit to `total`, so the order of runs shows in it. */ +export const add = System.create({ name: "joiningTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { + resources[total] = resources[total] * 10 + input.amount; +}); +export const append = System.create({ name: "joiningTest/append", input: { type: "object", properties: { name: { type: "string" } } }, resources: { write: [names] } }, ({ input, resources }) => { + resources[names] = [...resources[names], input.name]; +}); +export const spawn = System.create({ name: "joiningTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { + commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); +}); +export const fail = System.create({ name: "joiningTest/fail" }, () => { throw new Error("joiningTest failure"); }); +export const idle = System.create({ name: "joiningTest/idle" }, () => {}); + +/** The first value an observer gives that `accept` takes. */ +export const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +/** + * What a joined thread sees driving a world: runs in call order with struct and object input, by number, a failing + * run's number, observers of a resource, a selection, a count and an entity's listed components, and the + * rejections of a system the world never imported and of one without an id. + */ +export const drive = async (remote: World, unimported: System) => { + const runs = await Promise.all([ + remote.run(add, { amount: 1 }), remote.run(append, { name: "a" }), remote.run(add, { amount: 2 }), remote.run(append, { name: "b" }), remote.run(add, { amount: 3 }), + ]); + const failed = await remote.run(fail); + await remote.run(spawn, { x: 1 }); + await remote.run(spawn, { x: 2 }); + const observedTotal = await until(Observe.resource(remote, total), value => value === 123); + const selected = await until(Observe.select(remote, { required: [point], order: { [point]: false } }), entities => entities.length === 2); + const counted = await until(Observe.count(remote, { required: [point], where: { [point]: [2, -2] } }), count => count === 1); + const listed = await until(Observe.entity(remote, selected[0], { required: [point], optional: [marked] }), value => value !== null); + const rejected = (run: Promise): Promise => run.then(() => "ran", (error: unknown) => error instanceof Error ? error.message : String(error)); + return { + runs, + failed, + total: observedTotal, + selected: selected.length, + counted, + listed: listed === null ? null : [listed[point], listed[marked]], + unimported: await rejected(remote.run(unimported)), + unnamed: await rejected(remote.run(System.create(() => {}))), + }; +}; +export type Driven = Awaited>; + +/** What a joined thread saw driving a world, its runs' reports read on the world's thread, which holds them. */ +export const withReports = async (world: World, { runs, failed, ...driven }: Driven) => ({ + ...driven, + reports: await Promise.all(runs.map(run => reportOf(world, run))), + failures: (await reportOf(world, failed)).failures.map(failure => failure.message), +}); +export type Reported = Awaited>; diff --git a/packages/ecs/src/implementation/world/joining.ts b/packages/ecs/src/implementation/world/joining.ts new file mode 100644 index 000000000..9421ee646 --- /dev/null +++ b/packages/ecs/src/implementation/world/joining.ts @@ -0,0 +1,309 @@ +import type { World } from '../../interface/world/world.js'; +import type { WorkerError } from './protocol.js'; +import type { Schema } from '../../interface/schema/schema.js'; +import { layoutOf, viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; +import { lineWords } from './protocol.js'; + +// What a world's thread and a thread that joined it share: their messages, the joined thread's control words and run +// slots in shared memory, and how a run's input is written into a slot. + +/** Marks this channel's messages, so a port that carries others too, such as a worker's scope, can share it. */ +export const protocolName = "@adobe/ecs/join@1"; + +/** An index without an id, by its declaration, its keys by a joined thread's numbers: the world makes one like it. */ +export type ObserverIndex = { + readonly name: string; + readonly key: readonly number[]; + readonly has: readonly number[]; + readonly not: readonly number[]; + readonly sorted: boolean; + readonly unique: boolean; +}; + +/** A lookup of an observer system a joined thread declares: its keys by that thread's numbers, its index by id or declaration. */ +export type ObserverLookup = { + readonly name: string; + readonly read: readonly number[]; + readonly has: readonly number[]; + readonly not: readonly number[]; + /** Null where it doesn't declare `changed`. */ + readonly changed: readonly number[] | null; + readonly index: string | ObserverIndex | null; +}; + +/** What an observer system a joined thread declares reads, by that thread's numbers for its keys. */ +export type ObserverDeclaration = { + readonly components: { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly added: readonly number[]; readonly changed: readonly number[] } | null; + readonly resources: { readonly read: readonly number[]; readonly changed: readonly number[]; readonly is: readonly number[]; readonly not: readonly number[] } | null; + readonly lookup: readonly ObserverLookup[]; +}; + +/** An observer system the world places on a joined thread: its index there, the thread's own by its number or one by its id, and its block. */ +export type ObserverSetup = { + readonly index: number; + readonly local: number | null; + readonly id: string | null; + readonly keys: readonly (readonly [number, string])[]; + /** The byte offset of its block (`joined-observer.ts`). */ + readonly block: number; + /** + * For one reading only struct resources, the byte offset of a second block: its posts, in either, hold copies of + * what they read, so it runs them without this thread's objects. + */ + readonly spare: number | null; + readonly indexes: readonly (readonly [string, number])[]; +}; + +/** From a joined thread to the world's: messages are handled in the order they're posted. */ +export type ToWorld = { readonly protocol: typeof protocolName } & ( + /** + * First: the logical threads it claims; whether it can wait without blocking, so it's woken without a message; and + * the name of a Web Lock it holds while it lives, where the platform has them, which the world then requests, so + * it's granted when the thread ends however it ends. + */ + | { + readonly kind: "join"; readonly threads: readonly string[]; readonly waits: boolean; readonly lock: string | null; + /** With claims: the ids of the systems and finalizers this thread imported, so the world checks those it places here. */ + readonly imported: readonly string[]; + } + /** The id of a key or system it numbers `number`, posted before the first use of that number. */ + | { readonly kind: "name"; readonly number: number; readonly id: string; readonly item: boolean } + /** The input of run `sequence`, which its slot says comes by message. */ + | { readonly kind: "input"; readonly sequence: number; readonly input: unknown } + /** + * The input of run `sequence` of joined thread `from`, which its slot says moved to this thread, arrived here: the + * world starts that run once this arrives. + */ + | { readonly kind: "received"; readonly from: number; readonly sequence: number } + /** Where the world can't wait without blocking: runs were posted. */ + | { readonly kind: "wake" } + | { readonly kind: "leave" } + /** + * After task `sequence` is done, when its completion says a message follows: its systems' errors and spilled + * segments, by position in the task; posted after the thread's numbers for keys its commands named first. `stamps` + * are the objects tails wrote from their snapshots, as position in the task, entity and component, which the world + * stamps changed at their tick. + */ + | { + readonly kind: "completed"; readonly sequence: number; + readonly errors: readonly (readonly [number, WorkerError])[]; readonly spills: readonly (readonly [number, Uint8Array])[]; + readonly stamps?: readonly number[]; + } + /** What its finalizers threw, by their index on this thread, for the world's next report. */ + | { readonly kind: "finalized"; readonly errors: readonly (readonly [number, WorkerError])[] } + /** It dropped what it held for the systems unplaced at these indexes, which the world may place again. */ + | { readonly kind: "unplaced"; readonly indexes: readonly number[] } + /** + * An observer system of its own, by its number for it, which no other of its messages uses, with its label and what + * it reads; added to ticks with `added`. + */ + | { readonly kind: "observer"; readonly number: number; readonly name: string; readonly declaration: ObserverDeclaration; readonly added: boolean } + /** Its observer system `number` is added to ticks, removed from them, or no longer needed, its value collected. */ + | { readonly kind: "observer add" | "observer remove" | "observer drop"; readonly number: number } + /** What observer systems placed on it threw, by their indexes there, for the world's next report. */ + | { readonly kind: "observer failed"; readonly errors: readonly (readonly [number, WorkerError])[] } + /** It dropped the observer systems at these indexes, which the world unplaced. */ + | { readonly kind: "observer dropped"; readonly indexes: readonly number[] } + /** A post it claimed and couldn't run yet, owed to a run: the world posts it again between runs. */ + | { readonly kind: "observer again" } +); + +/** From the world's thread to a joined one. */ +export type ToJoined = { readonly protocol: typeof protocolName } & ( + /** + * The memory, and the byte offset of the thread's control words; whether the world can wait without blocking; the + * byte offsets of its move log's header and its task block (`thread-tasks.ts`), null without claims; and its + * number, which storage flags name. + */ + | { + readonly kind: "joined"; readonly memory: WebAssembly.Memory; readonly control: number; readonly waits: boolean; readonly log: number | null; + readonly tasks: number | null; readonly thread: number; + } + /** + * Systems the world places on the thread, each by its index there and its id, with the world's numbers for its keys + * and the byte offset of its removed word, which the thread checks before it runs the system. + */ + | { readonly kind: "systems"; readonly systems: readonly { readonly index: number; readonly id: string; readonly keys: readonly (readonly [number, string])[]; readonly removed: number; readonly indexes: readonly (readonly [string, number])[] }[] } + /** + * Systems the world removed, by their indexes there, whose removed words are set: the thread drops what it holds + * for them once it finished task `sequence`, the last posted before, and says so. + */ + | { readonly kind: "unplace"; readonly indexes: readonly number[]; readonly sequence: number } + /** Observer systems the world places on the thread, which run there after sync points. */ + | { readonly kind: "observer"; readonly systems: readonly ObserverSetup[] } + /** Observer systems the world removed, by their indexes there, whose removed words are set: the thread drops them and says so. */ + | { readonly kind: "unplace observers"; readonly indexes: readonly number[] } + /** Finalizers the world places on the thread, as systems are: each one's index there, its id, and the world's numbers for its keys. */ + | { readonly kind: "finalizers"; readonly finalizers: readonly { readonly index: number; readonly id: string; readonly keys: readonly (readonly [number, string])[] }[] } + /** + * The input of task `sequence`, which its payload says comes by message; or, with `held`, the input of run + * `held[1]` of joined thread `held[0]`, which moved here when it was run. + */ + | { readonly kind: "task input"; readonly sequence: number; readonly input: unknown; readonly held?: readonly [number, number] } + /** + * A channel to joined thread `thread`, claiming `threads`, through `port`: a run there of a system on one of them + * moves the Transferable values of its input here through it, and one here moves them there. + */ + | { readonly kind: "peer"; readonly thread: number; readonly threads: readonly string[]; readonly port: Parameters[0] } + /** Joined thread `thread` is gone: its channel closes, and its threads are the world's again. */ + | { readonly kind: "unpeer"; readonly thread: number } + /** Run `sequence` of joined thread `from`, whose input moved here, finished: what of it no task took is released. */ + | { readonly kind: "release"; readonly from: number; readonly sequence: number } + /** A component whose objects the thread fills, by the world's number, with its id: null for one without. */ + | { readonly kind: "component"; readonly component: number; readonly id: string | null } + | { readonly kind: "refused"; readonly message: string } + | { readonly kind: "rejected"; readonly sequence: number; readonly message: string } + /** Where the joined thread can't wait without blocking: runs completed, or a task or observer post waits for it. */ + | { readonly kind: "wake" } + | { readonly kind: "disposed" } +); + +/** From a joined thread to another, through their channel: the input of run `sequence`, moved from that thread. */ +export type ToPeer = { readonly protocol: typeof protocolName; readonly kind: "transfer"; readonly sequence: number; readonly input: unknown }; + +/** A joined thread's channel to the world, or the world's to it, over the port it was given; or a joined thread's to another. */ +export type ThreadChannel = { + /** Posts a message, moving the Transferable values `transfer` lists; throws for a value that can't be posted. */ + readonly post: (message: Send, transfer?: readonly object[]) => void; + /** Sets the one handler for this protocol's messages; others are ignored. */ + readonly onMessage: (handler: (message: Receive) => void) => void; + /** Sets the one handler for the other end's end: a `MessagePort`'s close, or a node worker's exit. */ + readonly onClose: (handler: () => void) => void; + /** Closes a `MessagePort`; a worker or a worker's scope stays as it is, its owner's to end. */ + readonly close: () => void; +}; + +/** Whether a port is a `MessagePort` this thread may close: not a worker, nor its scope, whose `close` ends the worker. */ +const isMessagePort = (port: Parameters[0]): boolean => + typeof globalThis.MessagePort === "function" && port instanceof globalThis.MessagePort + && port !== globalThis.process?.getBuiltinModule?.("node:worker_threads")?.parentPort; + +const isOurs = (data: unknown): boolean => typeof data === "object" && data !== null && Reflect.get(data, "protocol") === protocolName; + +/** + * A port as a channel: a browser `MessagePort`, `Worker` or worker scope through `addEventListener`, and a node + * `MessagePort`, `Worker` or `parentPort` through `on`. + */ +export const threadChannel = (port: Parameters[0]): ThreadChannel => { + const on: unknown = Reflect.get(port, "on"); + const listen: unknown = Reflect.get(port, "addEventListener"); + const close: unknown = Reflect.get(port, "close"); + const start: unknown = Reflect.get(port, "start"); + const postMessage: unknown = Reflect.get(port, "postMessage"); + const subscribe = (event: string, listener: (value: unknown) => void): void => { + if (typeof on === "function") Reflect.apply(on, port, [event, listener]); + else if (typeof listen === "function") Reflect.apply(listen, port, [event, listener]); + }; + return { + post: (message, transfer) => { + if (typeof postMessage === "function") Reflect.apply(postMessage, port, transfer === undefined || transfer.length === 0 ? [message] : [message, transfer]); + }, + onMessage: handler => { + // Node gives the data itself; the browser, an event holding it. + const node = typeof on === "function"; + subscribe("message", value => { + const data: unknown = node ? value : typeof value === "object" && value !== null ? Reflect.get(value, "data") : undefined; + // A message marked with the protocol is one the other end posted as `Receive`. + if (isOurs(data)) handler(data as Receive); + }); + if (!node && typeof start === "function") Reflect.apply(start, port, []); + }, + onClose: handler => { + let closed = false; + const once = (): void => { + if (closed) return; + closed = true; + handler(); + }; + subscribe("close", once); + if (typeof on === "function") subscribe("exit", once); + }, + close: () => { if (isMessagePort(port) && typeof close === "function") Reflect.apply(close, port, []); }, + }; +}; + +/** + * A joined thread's control words, each on a line of its own: the signal it waits on, which the world bumps when runs + * complete or a task or observer post waits for it, beside whether a wake is unread, which the thread clears before it + * reads what's new, so the world notifies only a thread that has read every earlier wake; the last run it posted; and + * the last run the world completed. Then its run slots. + */ +export const signalWord = 0; +export const unreadWord = 1; +export const postedWord = lineWords; +export const completedWord = 2 * lineWords; +export const slotsWord = 3 * lineWords; + +/** + * Runs in flight a joined thread holds room for, each in slot `(sequence - 1) % runSlots`: its status and the run's + * number, which the world writes before completing it, the system's number, and its input's length in bytes, then the + * input. + */ +export const runSlots = 64; +export const slotWords = 64; +export const slotStatus = 0; +export const slotNumber = 1; +export const slotRun = 2; +export const slotInput = 3; +export const slotPayload = 4; +export const controlBytes = (slotsWord + runSlots * slotWords) * 4; +/** Input written in its slot, at most this many bytes. */ +export const inlineInputBytes = (slotWords - slotPayload) * 4; +/** + * A slot's input length for none, for an input posted as a message, and for one moved to the joined thread its system + * runs on, whose number is the slot's first payload word. + */ +export const noInput = -1; +export const inputByMessage = -2; +export const inputMoved = -3; +/** A slot's system number for a run whose input couldn't be posted, which the world completes without running. */ +export const unposted = -1; +/** Slot statuses: in flight; done, its number in the slot; rejected, the rejection posted. */ +export const pending = 0; +export const done = 1; +export const rejected = 2; + +/** The schema with every number widened to f64, so a run's input crosses exactly as given. */ +const widened = (schema: Schema): Schema => { + if (!("type" in schema)) return schema; + switch (schema.type) { + case "number": + case "integer": return { type: "number" }; + case "array": return { ...schema, items: widened(schema.items) }; + case "object": { + const properties: { [name: string]: Schema } = {}; + for (const name of Object.keys(schema.properties)) properties[name] = widened(schema.properties[name]); + return { ...schema, properties }; + } + default: return schema; + } +}; + +const inputLayouts = new WeakMap(); + +/** + * How a system's input is written into a slot: a struct schema of at most `inlineInputBytes`, with every number an + * f64; null for any other, whose input is posted. Both threads compute it from the same declaration. + */ +export const inputLayoutOf = (system: object, schema: Schema | undefined): Layout | null => { + const known = inputLayouts.get(system); + if (known !== undefined) return known; + const layout = schema === undefined ? null : layoutOf(widened(schema)); + const inline = layout !== null && layout.kind === "linear" && layout.stride <= inlineInputBytes ? layout : null; + inputLayouts.set(system, inline); + return inline; +}; + +/** Whether a value has every field a struct input's schema names, so writing it into a slot loses nothing. */ +export const fitsInline = (schema: Schema | undefined, value: unknown): boolean => { + if (schema === undefined || !("type" in schema) || schema.type !== "object" || typeof value !== "object" || value === null) return false; + for (const name of Object.keys(schema.properties)) if (!Object.hasOwn(value, name)) return false; + return true; +}; + +/** Views of each slot's input, made once per buffer: the control words never move, and memory only grows. */ +export const slotViews = (buffer: ArrayBufferLike, control: number): readonly Views[] => + Array.from({ length: runSlots }, (_, slot) => viewsOf(buffer, control + (slotsWord + slot * slotWords + slotPayload) * 4, inlineInputBytes)); + +/** The platform's Web Locks, where it has them, by which a thread that ends however it ends releases what it held. */ +export const platformLocks = (): LockManager | null => globalThis.navigator?.locks ?? null; diff --git a/packages/ecs/src/implementation/world/journal.ts b/packages/ecs/src/implementation/world/journal.ts new file mode 100644 index 000000000..f0598355c --- /dev/null +++ b/packages/ecs/src/implementation/world/journal.ts @@ -0,0 +1,36 @@ +/** + * Entity indexes whose archetype or references sync points changed: spawned, despawned, moved to another archetype, + * or written a reference. Kept only while some reader, an `up` system or an index, needs it. Positions count every + * index ever appended, so each reader keeps its own; a position before `first` lost entries, which a reader then + * rebuilds from. + */ +export type Journal = { + indexes: Int32Array; + length: number; + /** The position of `indexes[0]`. */ + first: number; + readers: number; +}; + +export const createJournal = (): Journal => ({ indexes: new Int32Array(0), length: 0, first: 0, readers: 0 }); + +/** The position after the last entry. */ +export const journalEnd = (journal: Journal): number => journal.first + journal.length; + +/** + * Appends an entity index, once some reader exists. Past `limit` entries the journal drops them: a reader behind + * then rebuilds, which costs about what reading that many entries would. + */ +export const record = (journal: Journal, index: number, limit: number): void => { + if (journal.readers === 0) return; + if (journal.length >= Math.max(4096, limit)) { + journal.first += journal.length; + journal.length = 0; + } + if (journal.length === journal.indexes.length) { + const grown = new Int32Array(Math.max(1024, 2 * journal.indexes.length)); + grown.set(journal.indexes); + journal.indexes = grown; + } + journal.indexes[journal.length++] = index; +}; diff --git a/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts new file mode 100644 index 000000000..f469850ab --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts @@ -0,0 +1,17 @@ +import { Archetype, Component, Vec3 } from "../../index.js"; + +export const position = Symbol("kernelImports/position"); +export const spark = Symbol("kernelImports/spark"); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3 }; + [spark]: { type: true }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(spark, { type: { const: true } }); + +/** A list a kernel imports: the build reads its components here, and compiles the kernel again when this module changes. */ +export const sparks = Archetype.create([position, spark]); diff --git a/packages/ecs/src/implementation/world/kernel-imports-steer.test-helper.ts b/packages/ecs/src/implementation/world/kernel-imports-steer.test-helper.ts new file mode 100644 index 000000000..612a050fb --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-imports-steer.test-helper.ts @@ -0,0 +1,9 @@ +import { Vec3 } from "../../index.js"; + +/** A pure helper a kernel imports: the build inlines it, and it still runs as written everywhere else. */ +export const steer = (velocity: Vec3, toward: Vec3, limit: number): Vec3 => { + const desired = Vec3.subtract(toward, velocity); + const length = Vec3.length(desired); + if (length <= limit) return Vec3.add(velocity, desired); + return Vec3.add(velocity, Vec3.scale(desired, limit / length)); +}; diff --git a/packages/ecs/src/implementation/world/kernel-imports.test.ts b/packages/ecs/src/implementation/world/kernel-imports.test.ts new file mode 100644 index 000000000..82df016c0 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-imports.test.ts @@ -0,0 +1,73 @@ +import { expect, it } from "vitest"; +import { Component, Entity, System, Vec3, World } from "../../index.js"; +import { Observe } from "../../observe/index.js"; +import { position, spark, sparks } from "./kernel-imports-sparks.test-helper.js"; +import { steer } from "./kernel-imports-steer.test-helper.js"; +import { createWorld } from "../index.js"; +import { workers } from "../../interface/resources/workers.js"; + +const velocity = Symbol("kernelImports/velocity"); +const target = Symbol("kernelImports/target"); + +declare module "@adobe/ecs" { + interface Components { + [velocity]: { type: Vec3 }; + [target]: { type: Entity }; + } +} + +Component.register(velocity, { type: Vec3.schema }); +Component.register(target, { type: Entity.schema }); + +const spawn = System.create({ name: "kernelImports/spawn" }, ({ commands }) => { + const leader = commands.spawn([position, velocity])({ [position]: [10, 0, 0], [velocity]: [0, 0, 0] }); + commands.spawn([position, velocity, target])({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [target]: leader }); +}); + +// `kernel: "cpu"` compiles the body at build time, to run in chunks across workers as WASM where it can. +const integrate = System.create({ name: "kernelImports/integrate", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { + row[position] = Vec3.add(row[position], row[velocity]); +}); + +// Kernels use lookups, imported pure functions, commands and imported lists too. +const chase = System.create({ + name: "kernelImports/chase", + kernel: "cpu", + components: { read: [position, target], write: [velocity] }, + lookup: { targets: { read: [position] } }, +}, (row, { lookup, commands }) => { + const other = lookup.targets(row[target]); + if (other === null) return; + row[velocity] = steer(row[velocity], Vec3.subtract(other[position], row[position]), 2); + // A kernel builds tuple values from their leaves. + const [x, y, z] = row[position]; + if (Vec3.distance(other[position], row[position]) < 1) commands.spawn(sparks)({ [position]: [x, y, z] }); +}); + +it("runs systems compiled as kernels", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn); + for (let step = 0; step < 6; step++) { + await world.run(chase); + await world.run(integrate); + } + const [follower] = await Observe.toPromise(Observe.select(world, { required: [target] })); + expect((await Observe.toPromise(Observe.entity(world, follower, { required: [position] })))?.[position][0]).toBeGreaterThan(9); + expect(await Observe.toPromise(Observe.count(world, { required: [spark] }))).toBeGreaterThan(0); +}); + +// A kernel's `add` and `remove` take an imported `Symbol(...)` as one key, and an imported list as a list. +const ignite = System.create({ name: "kernelImports/ignite" }, ({ commands }) => { + commands.spawn(sparks)({ [position]: [0, 0, 0] }); +}); +const settle = System.create({ name: "kernelImports/settle", kernel: "cpu", components: { has: [spark] } }, (row, { commands }) => { + commands.remove(row.entity, spark); +}); + +it("removes an imported key in a kernel", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(ignite); + await world.run(settle); + expect(await Observe.toPromise(Observe.count(world, { required: [spark] }))).toBe(0); + expect(await Observe.toPromise(Observe.count(world, { required: [position] }))).toBe(1); +}); diff --git a/packages/ecs/src/implementation/world/kernel-indexes.ts b/packages/ecs/src/implementation/world/kernel-indexes.ts new file mode 100644 index 000000000..289ce0bf9 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-indexes.ts @@ -0,0 +1,165 @@ +import { samePath, type Path } from '../../interface/registry/layout.js'; +import { labelOf, watchedBy } from '../../interface/registry/index.js'; +import type { KernelDescription } from './kernel.js'; +import { partWords } from './index-keys.js'; +import { sortedStride } from './index-sorted.js'; +import { recordKey } from './index-hash.js'; +import type { WorldIndex } from './indexes.js'; +import type { IndexReaderSetup, IndexSource, RowLeaf } from './index-readers.js'; +import { idOf, type Key, type State } from './state.js'; +import type { WasmIndexLookup, WasmKeyValue, WasmProbe } from './wasm.js'; + +/** + * A kernel's lookup through an index, bound to the world's index: by the kernel's probes and row leaves, how it + * encodes keys and reads rows, for its WASM and its JS, and the components it watches. + */ +export type KernelIndex = { + readonly lookup: number; + readonly index: WorldIndex; + readonly reader: IndexReaderSetup; + readonly wasm: WasmIndexLookup; + readonly watched: readonly number[]; +}; + +/** The key values a probe's argument gives a part, by its leaves' paths: each leaf's word and stored type. */ +const valuesOf = (paths: readonly Path[], part: WorldIndex["parts"][number], fail: (message: string) => never, what: string): WasmKeyValue[] => { + if (part.kind !== "leaves") return fail(`${what} isn't a struct, which kernels look up by`); + const leaves = part.leaves; + if (paths.length !== leaves.length || !paths.every((path, k) => samePath(path, leaves[k].path))) { + return fail(`${what} is given leaves ${JSON.stringify(paths)}, but its leaves are ${JSON.stringify(leaves.map(leaf => leaf.path))}; give each leaf, destructuring it or indexing its fields`); + } + const values: WasmKeyValue[] = []; + let word = part.first; + for (const leaf of leaves) { + values.push({ word, scalar: leaf.scalar }); + word += leaf.scalar === "f64" ? 2 : 1; + } + return values; +}; + +/** + * Binds a kernel's lookups through indexes to the indexes `byName` gives, by lookup, null for one by entity. Throws for + * a probe whose key doesn't match its index's: a hash index takes the whole key, a sorted one a prefix whose last part + * may be a comparison, and each part all its leaves. + */ +export const kernelIndexesOf = (state: State, description: KernelDescription, lookups: readonly { readonly read?: readonly Key[]; readonly changed?: readonly Key[] | true; readonly index?: unknown }[], byName: ReadonlyMap, fail: (message: string) => never): readonly (KernelIndex | null)[] => + description.lookups.map((name, lookup): KernelIndex | null => { + const index = byName.get(name); + if (index === undefined) return null; + const declared = lookups[lookup]; + const reads = (declared.read ?? []).map(key => idOf(state, key)); + const sorted = index.sorted !== null; + const { parts, words } = index; + const probes = (description.probes ?? []).map((probe, site): WasmProbe | null => { + if (probe.lookup !== lookup) return null; + const what = (part: number): string => `lookup "${name}"'s key part "${labelOf(state.keys[index.keys[part]])}" at probe ${site}`; + const values: WasmKeyValue[] = []; + let prefixWords = 0; + let comparisons: WasmProbe["comparisons"] = null; + if (probe.arguments.length > parts.length) return fail(`lookup "${name}" is given more key parts than its index has`); + if (!sorted && probe.arguments.length !== parts.length) return fail(`lookup "${name}" goes through a hash index, which takes the whole key`); + probe.arguments.forEach((argument, p) => { + const part = parts[p]; + if (argument.kind === "value") { + values.push(...valuesOf(argument.paths, part, fail, what(p))); + prefixWords = part.first + partWords(part); + return; + } + if (!sorted) fail(`lookup "${name}" compares a key part, which only a sorted index takes`); + prefixWords = part.first; + comparisons = argument.operators.map(({ operator, paths }) => ({ operator, values: valuesOf(paths, part, fail, what(p)), words: part.first + partWords(part) })); + }); + return { values, prefixWords, comparisons }; + }); + const rowLeaves = (description.rowLeaves ?? []).map(([of, read, path]): { readonly read: number; readonly leaf: RowLeaf; readonly offset: number; readonly stride: number } | null => { + if (of !== lookup) return null; + const component = reads[read]; + const layout = component === undefined ? undefined : state.layouts[component]; + if (layout?.kind !== "linear") return fail(`lookup "${name}" reads a row's value that isn't stored in linear memory`); + const found = layout.leaves.find(leaf => samePath(leaf.path, path)); + if (found === undefined) return fail(`lookup "${name}" reads "${labelOf(state.keys[component])}" at [${path.join(", ")}], which isn't a scalar of it; destructure tuples and structs or index their fields`); + return { read, leaf: { component, view: found.view, unitStride: found.unitStride, unitOffset: found.unitOffset, boolean: found.scalar === "boolean" }, offset: found.offset, stride: layout.stride }; + }); + const watched = watchedBy(declared).map(key => idOf(state, key)); + const stride = sorted ? sortedStride(words) : recordKey + words; + return { + lookup, + index, + reader: { kind: sorted ? "sorted" : "hash", words, stride, probes, rowLeaves: rowLeaves.map(found => found?.leaf ?? null) }, + wasm: { + kind: sorted ? "sorted" : "hash", + words, + stride, + probe: site => probes[site] ?? null, + rowLeaf: leaf => { + const found = rowLeaves[leaf]; + return found === null || found === undefined ? null : { read: found.read, column: { view: found.leaf.view, stride: found.stride, offset: found.offset, boolean: found.leaf.boolean } }; + }, + watched: watched.length, + }, + watched, + }; + }); + +/** Writes a lookup through an index's WASM parameters into `out` from `at`, as of now: its arrays' addresses, counts and ticks. */ +export const writeIndexParameters = (bound: Pick, since: number, out: number[], at: number): number => { + const { hash, sorted } = bound.index; + let next = at; + if (hash !== null) { + out[next++] = hash.slots.byteOffset; + out[next++] = (hash.slots.length >> 1) - 1; + out[next++] = hash.records.byteOffset; + out[next++] = hash.entries.byteOffset; + out[next++] = hash.ticks.byteOffset; + out[next++] = hash.cleared; + out[next++] = since; + for (const component of bound.watched) out[next++] = hash.watched.get(component)?.byteOffset ?? 0; + return next; + } + if (sorted === null) return next; + out[next++] = sorted.fences.byteOffset; + out[next++] = sorted.blocks; + out[next++] = sorted.order.byteOffset; + out[next++] = sorted.lengths.byteOffset; + out[next++] = sorted.entries.byteOffset; + out[next++] = sorted.counts.byteOffset; + out[next++] = sorted.size; + out[next++] = sorted.tree.byteOffset; + out[next++] = sorted.leaves; + out[next++] = sorted.cleared; + out[next++] = since; + for (const component of bound.watched) out[next++] = sorted.watched.get(component)?.tree.byteOffset ?? 0; + return next; +}; + +/** Points a JS reader's source at the world's index on its own thread, as of now. */ +export const refreshSource = (source: IndexSource, bound: KernelIndex, since: number): void => { + const { hash, sorted } = bound.index; + source.since = since; + if (hash !== null) { + source.slots = hash.slots; source.slotsBase = 0; source.mask = (hash.slots.length >> 1) - 1; + source.records = hash.records; source.recordsBase = 0; + source.hashEntries = hash.entries; source.hashEntriesBase = 0; + source.ticks = hash.ticks; source.ticksBase = 0; + source.cleared = hash.cleared; + source.watched.length = source.watchedBases.length = 0; + for (const component of bound.watched) { + source.watched.push(hash.watched.get(component) ?? new Float64Array(0)); + source.watchedBases.push(0); + } + return; + } + if (sorted === null) return; + source.fences = sorted.fences; source.fencesBase = 0; source.blocks = sorted.blocks; + source.order = sorted.order; source.orderBase = 0; + source.lengths = sorted.lengths; source.lengthsBase = 0; + source.sortedEntries = sorted.entries; source.sortedEntriesBase = 0; + source.counts = sorted.counts; source.countsBase = 0; source.size = sorted.size; + source.tree = sorted.tree; source.treeBase = 0; source.leaves = sorted.leaves; + source.cleared = sorted.cleared; + source.watched.length = source.watchedBases.length = 0; + for (const component of bound.watched) { + source.watched.push(sorted.watched.get(component)?.tree ?? new Float64Array(0)); + source.watchedBases.push(0); + } +}; diff --git a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts new file mode 100644 index 000000000..a258390a3 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts @@ -0,0 +1,30 @@ +import { Component } from '../../interface/component/component.js'; +import { Entity } from '../../interface/entity/entity.js'; +import { System } from '../../interface/system/system.js'; + +/** A kernel whose lookup reads a value outside linear memory, which `System.create` rejects as this module runs. */ + +const target = Symbol("kernelLookupTest/target"); +const label = Symbol("kernelLookupTest/label"); +const offset = Symbol("kernelLookupTest/offset"); +declare module "@adobe/ecs" { + export interface Components { + [target]: { type: Entity }; + [label]: { type: string }; + [offset]: { type: number }; + } +} +Component.register(target, { type: Entity.schema }); +Component.register(label, { type: { type: "string" } }); +Component.register(offset, { type: { type: "number", precision: 1 } }); + +export const labels = System.create({ + name: "kernelLookupTest/labels", + kernel: "cpu", + components: { read: [target], write: [offset] }, + // @ts-expect-error kernel lookups read linear values + lookup: { named: { read: [label] } }, +}, (row, { lookup: { named } }) => { + const found = named(row[target]); + row[offset] = found === null ? 0 : +found[label]; +}); diff --git a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts new file mode 100644 index 000000000..64d423dd5 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts @@ -0,0 +1,20 @@ +import { Component } from '../../interface/component/component.js'; +import { System } from '../../interface/system/system.js'; + +/** A kernel reading a leaf its component's schema doesn't have, which registering its compiled kernel rejects as this module runs. */ + +const pair = Symbol("kernelPathTest/pair"); +const sum = Symbol("kernelPathTest/sum"); +declare module "@adobe/ecs" { + export interface Components { + [pair]: { type: readonly [number, number] }; + [sum]: { type: number }; + } +} +Component.register(pair, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(sum, { type: { type: "number" } }); + +export const beyond = System.create({ name: "kernelPathTest/beyond", kernel: "cpu", components: { read: [pair], write: [sum] } }, row => { + // @ts-expect-error a pair has no third item + row[sum] = row[pair][2]; +}); diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts new file mode 100644 index 000000000..992f370a7 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { Archetype } from "../../interface/archetype/archetype.js"; +import { Component } from "../../interface/component/component.js"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import { count, read, readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { indexOf } from "./entities.js"; +import { retainedRuns } from "./reports.js"; +import { fillReports } from "./reports.test-helper.js"; + +const index = Symbol("kernelSpawnsTest/index"); +const value = Symbol("kernelSpawnsTest/value"); +const extra = Symbol("kernelSpawnsTest/extra"); +const flag = Symbol("kernelSpawnsTest/flag"); +const made = Symbol("kernelSpawnsTest/made"); +const name = Symbol("kernelSpawnsTest/name"); +const position = Symbol("kernelSpawnsTest/position"); + +declare module "@adobe/ecs" { + export interface Components { + [index]: { type: number }; + [value]: { type: number }; + [extra]: { type: number }; + [flag]: { type: true }; + [made]: { type: true }; + [name]: { type: string | null }; + [position]: { type: readonly [number, number] }; + } +} + +Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(extra, { type: { type: "number", precision: 1 } }); +Component.register(flag, { type: { const: true } }); +Component.register(made, { type: { const: true } }); +Component.register(name, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); +Component.register(position, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); + +const spawned = Archetype.create([value, made]); + +const seed = System.create({ name: "kernelSpawnsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); +}); + +/** One spawn per row, from a kernel. */ +const fromKernel = System.create({ name: "kernelSpawnsTest/fromKernel", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { + commands.spawn(spawned)({ [value]: row[index] }); +}); + +/** The same archetype from a main-thread system, before the kernel. */ +const fromMain = System.create({ name: "kernelSpawnsTest/fromMain", before: [fromKernel] }, ({ commands }) => { + for (let i = 0; i < 3; i++) commands.spawn(spawned)({ [value]: 100 + i }); +}); + +/** Spawns, then adds to and removes from what it spawned, in the same kernel and tick. */ +const spawnAndEdit = System.create({ name: "kernelSpawnsTest/spawnAndEdit", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { + const entity = commands.spawn([value, flag, made])({ [value]: row[index] }); + commands.add(entity, extra, row[index] * 10); + commands.remove(entity, flag); +}); + +/** A spawn with a tuple, and an object component left to its start. */ +const withObjects = System.create({ name: "kernelSpawnsTest/withObjects", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { + commands.spawn([value, position, name, made])({ [value]: row[index], [position]: [row[index], -row[index]] }); +}); + +const despawnMade = System.create({ name: "kernelSpawnsTest/despawnMade", components: { has: [made] } }, (row, { commands }) => { commands.despawn(row.entity); }); + +const added = { seen: 0 }; +const countAdded = System.create({ name: "kernelSpawnsTest/countAdded", components: { added: [value], has: [made] } }, () => { added.seen++; }); + +/** Each made entity's value, in storage order. */ +const madeValues: number[] = []; +const readMade = System.create({ name: "kernelSpawnsTest/readMade", observer: true, components: { read: [value], has: [made] } }, row => { madeValues.push(row[value]); }); +const valuesOf = async (world: ReturnType): Promise => { + madeValues.length = 0; + await world.run(readMade); + return [...madeValues]; +}; + +describe("kernel spawns", () => { + it("land with their values and change ticks, seen once by an added filter", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count: 5 }); + await world.run(fromKernel); + expect(await valuesOf(world)).toEqual([0, 1, 2, 3, 4]); + added.seen = 0; + await world.run(countAdded); + await world.run(countAdded); + expect(added.seen).toBe(5); + await world.dispose(); + }); + + it("join main-thread spawns of the same archetype: the main thread's first, then the kernel's in row order", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count: 4 }); + const frame = System.create({ name: "kernelSpawnsTest/mixed", systems: [fromKernel, fromMain] }); + await world.run(frame); + expect(await valuesOf(world)).toEqual([100, 101, 102, 0, 1, 2, 3]); + await world.run(frame); + expect(await count(world, { required: [made] })).toBe(14); + await world.dispose(); + }); + + it("can be added to and removed from by commands later in the same tick", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count: 3 }); + await world.run(spawnAndEdit); + const entities = await select(world, { required: [made] }); + expect(entities).toHaveLength(3); + expect(await readEach(world, entities, extra)).toEqual([0, 10, 20]); + expect(await readEach(world, entities, value)).toEqual([0, 1, 2]); + expect(await count(world, { required: [flag] })).toBe(0); + added.seen = 0; + await world.run(countAdded); + expect(added.seen).toBe(3); + await world.dispose(); + }); + + it("write tuples, and the start of an object component left out", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count: 2 }); + await world.run(withObjects); + const entities = await select(world, { required: [made] }); + expect(await readEach(world, entities, position)).toEqual([[0, -0], [1, -1]]); + expect(await readEach(world, entities, name)).toEqual([null, null]); + expect(await readEach(world, entities, value)).toEqual([0, 1]); + expect(await read(world, entities[0], index)).toBeNull(); + await world.dispose(); + }); + + it("reuse the indexes their blocks of reservations leave unused, so a few spawns a run don't grow the entity table", async () => { + const world = createWorld({ [workers]: 0 }); + // Past the runs that spawn reports, which take indexes of their own. + await fillReports(world); + await world.run(seed, { count: 3 }); + const seen = new Set(); + for (let run = 0; run < 50; run++) { + await world.run(fromKernel); + const entities = await select(world, { required: [made] }); + expect(entities).toHaveLength(3); + for (const entity of entities) seen.add(indexOf(entity)); + await world.run(despawnMade); + } + // Each run reserves a block; what it leaves unused goes back to the free list, and is taken again. + expect(Math.max(...seen)).toBeLessThan(retainedRuns + 32); + await world.dispose(); + }); + + it("stage each run from empty batches", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count: 3 }); + await world.run(fromKernel); + await world.run(fromKernel); + expect(await valuesOf(world)).toEqual([0, 1, 2, 0, 1, 2]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/kernel.ts b/packages/ecs/src/implementation/world/kernel.ts new file mode 100644 index 000000000..52b19897e --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel.ts @@ -0,0 +1,244 @@ +import type { WgslType } from '../../interface/math/intrinsic.js'; +import type { Entity } from '../../interface/entity/entity.js'; +import type { Reduction } from '../../interface/resource/reduction.js'; + +type Key = string | symbol; + +/** A path from a component's value to one scalar leaf: `[]` for a scalar, `[1]` for a tuple element, `["x"]` for a struct field. */ +export type Path = readonly (number | string)[]; + +/** + * One typed array the kernel receives, in `columns` order. A row's element is at `row * strides[k] + offsets[k]` + * (see `KernelModule.create`): a component's values are interleaved in its column, so a field is a strided view of its bytes. + */ +export type Column = + /** Float64Array of the row's entity. */ + | { readonly kind: "entity" } + /** A view of one scalar leaf. Registration checks the path names a scalar leaf of the schema. */ + | { readonly kind: "field"; readonly component: Key; readonly path: Path } + /** Float64Array of the component's changed ticks, stamped with `tick` on each write. */ + | { readonly kind: "changed"; readonly component: Key } + /** + * Float64Array of the component's newest changed tick per chunk of `chunkRows` rows, so change filters can skip + * unchanged chunks. Every `changed` column is followed by one, and each stamp sets both: `chunks[row >> chunkShift] = tick`. + */ + | { readonly kind: "chunks"; readonly component: Key }; + +/** Rows per chunk of change ticks is `2 ** chunkShift`. */ +export const chunkShift = 10; + +/** A leaf of a resource a kernel reduces into, folded with `operator`. */ +export type Reduced = { readonly resource: Key; readonly operator: Reduction; readonly path: Path }; + +/** A whole-value write assumed exactly these leaves. Registration checks they are all of the schema's leaves. */ +export type Shape = { readonly component: Key; readonly leaves: readonly Path[] }; + +/** + * Processes rows `start` to `end` (exclusive). Change filters are applied by the caller, which passes only passing ranges. + * - `resources`: values in `KernelDescription.resources` order. + * - `lookups`: functions in `KernelDescription.lookups` order, returning the lookup's `read` values in declared order, or `null`; + * for a lookup through an index, its reader (`world/index-readers.ts`). + * - `keys`: `KernelDescription.keys`, for anything that needs a component's key. + * - `commands`: issue commands without allocating: values are evaluated first, then `begin`, then each `value` or + * `component`, naming components by index into `keys`; a spawn calls its list's spawner with the values. + */ +export type Kernel = ( + columns: readonly (Float64Array | Float32Array | Int32Array | Uint32Array | Uint8Array)[], + start: number, + end: number, + resources: readonly unknown[], + lookups: readonly unknown[], + keys: readonly (Key | number)[], + args: unknown, + commands: KernelCommands, + tick: number, + /** Each reduction's partial for this call, at `partial + k`: loaded first, folded into, and stored last. */ + partials: Float64Array, + partial: number, + /** For a kernel that reads `up`: the rows it runs are `rows[start]` to `rows[end - 1]`, rather than `start` to `end - 1`. */ + rows?: Int32Array, + /** Each row's parent row, in the parent archetype every row of the call shares. */ + parents?: Int32Array, + /** That archetype's columns of `KernelDescription.upColumns`, empty without a parent. */ + upColumns?: readonly (Float64Array | Float32Array | Int32Array | Uint32Array | Uint8Array)[], + /** Whether the rows have a parent with every component `up` reads. */ + found?: boolean, +) => void; + +/** How kernels issue commands. `kind` is 1 for despawn, 2 for add, 3 for remove; `key` indexes the kernel's `keys`. */ +export type KernelCommands = { + /** + * By index into `KernelDescription.spawns`: stages a spawn of that list's components, taking one value per + * component in the list's order, `undefined` for its start, and returns the entity it reserved. + */ + readonly spawners: readonly ((...values: unknown[]) => number)[]; + readonly begin: (kind: number, entity: number) => void; + /** A component and its value for the current spawn or add; `undefined` is the component's start. */ + readonly value: (key: number, value: unknown) => void; + /** A component for the current remove. */ + readonly component: (key: number) => void; +}; + +/** + * A kernel's row body as data, for generating WASM at registration. Every value is a number: booleans are 0 or 1, + * typed as booleans where the generator needs to tell them apart. Control flow mirrors WASM's structured blocks. + */ +export namespace Program { + /** A WGSL value type an intrinsic takes or returns, made of one lane per scalar leaf. */ + export type ValueType = WgslType; + + /** A WGSL builtin a function matches: `operation(operands)`, or an operator between or before them. */ + export type Intrinsic = { + readonly operation: string; + readonly form: "call" | "infix" | "prefix"; + readonly parameters: readonly ValueType[]; + readonly result: ValueType; + }; + + export type Expression = + | { readonly kind: "number"; readonly value: number } + | { readonly kind: "boolean"; readonly value: boolean } + /** `columns[column][row]`, an entity or field column. */ + | { readonly kind: "column"; readonly column: number } + /** A scalar inside `resources[index]`, or inside `args`. */ + | { readonly kind: "resource"; readonly index: number; readonly path: Path } + | { readonly kind: "args"; readonly path: Path } + | { readonly kind: "local"; readonly local: number } + | { readonly kind: "unary"; readonly operator: "-" | "+" | "!" | "~"; readonly operand: Expression } + | { + readonly kind: "binary"; + readonly operator: "+" | "-" | "*" | "/" | "%" | "**" | "<" | ">" | "<=" | ">=" | "==" | "!=" | "===" | "!==" | "&" | "|" | "^" | "<<" | ">>" | ">>>"; + readonly left: Expression; + readonly right: Expression; + } + /** JS semantics: the result is an operand's value, not a boolean. */ + | { readonly kind: "logical"; readonly operator: "&&" | "||"; readonly left: Expression; readonly right: Expression } + | { readonly kind: "conditional"; readonly test: Expression; readonly consequent: Expression; readonly alternate: Expression } + /** `Math[name](...arguments)`; Math constants are emitted as numbers. */ + | { readonly kind: "math"; readonly name: string; readonly arguments: readonly Expression[] } + /** Whether the last `find` into `handle` found a matching entity. */ + | { readonly kind: "found"; readonly handle: number } + /** + * A scalar leaf of a found entity: component `read` (an index into the lookup's `read` list), at `path` within it. + * Reads NaN if nothing was found, as `undefined` does in arithmetic. + */ + | { readonly kind: "lookupLeaf"; readonly handle: number; readonly read: number; readonly path: Path } + /** Whether the rows of the call have a parent with every component `up` reads. */ + | { readonly kind: "upFound" } + /** `upColumns[column]` at the row's parent row; only under a test proving `upFound`. */ + | { readonly kind: "upLeaf"; readonly column: number } + /** Lane `lane` of intrinsic region `intrinsic`'s result, which `value` computes from the region's body. */ + | { readonly kind: "lane"; readonly intrinsic: number; readonly lane: number; readonly value: Expression } + /** How many rows the list in `handle` holds, a lookup through an index's result. */ + | { readonly kind: "listLength"; readonly handle: number } + /** Whether that list changed since the system last ran (`System`'s `changed` of a list). */ + | { readonly kind: "listChanged"; readonly handle: number } + /** + * A scalar leaf of a row a lookup through an index gave, in row handle `handle`: `KernelDescription.rowLeaves[leaf]`. + * Reads NaN for no row. + */ + | { readonly kind: "rowLeaf"; readonly handle: number; readonly leaf: number } + /** The entity of that row, or NaN for no row. */ + | { readonly kind: "rowEntity"; readonly handle: number }; + + export type Statement = + | { readonly kind: "set"; readonly local: number; readonly value: Expression } + /** `columns[column][row] = value`, with typed-array conversion. */ + | { readonly kind: "store"; readonly column: number; readonly value: Expression } + /** `columns[column][row] = tick` and `columns[chunks][row >> chunkShift] = tick`, for a changed column and its chunk column. */ + | { readonly kind: "stamp"; readonly column: number; readonly chunks: number } + | { readonly kind: "if"; readonly test: Expression; readonly consequent: readonly Statement[]; readonly alternate: readonly Statement[] } + /** WASM `block`: a branch to it exits it. */ + | { readonly kind: "block"; readonly body: readonly Statement[] } + /** WASM `loop`: a branch to it starts it again; falling off the end exits it. */ + | { readonly kind: "loop"; readonly body: readonly Statement[] } + /** WASM `br`/`br_if`: `depth` 0 is the innermost enclosing block or loop. */ + | { readonly kind: "branch"; readonly depth: number } + | { readonly kind: "branchIf"; readonly depth: number; readonly test: Expression } + /** Ends the current row. */ + | { readonly kind: "next" } + /** Looks `entity` up through lookup `lookup` (an index into `KernelDescription.lookups`), into `handle`. */ + | { readonly kind: "find"; readonly handle: number; readonly lookup: number; readonly entity: Expression } + /** + * Looks the key `values` give up through `KernelDescription.probes[probe]`'s index, into list handle `list`, and + * its first row into row handle `first`, found when the list isn't empty. + */ + | { readonly kind: "probe"; readonly list: number; readonly first: number; readonly probe: number; readonly values: readonly Expression[] } + /** Row `index` of list handle `list`, into row handle `row`, found when it's within the list. */ + | { readonly kind: "listRow"; readonly row: number; readonly list: number; readonly index: Expression } + /** + * A call to a function that matches a WGSL builtin. `body` is the function inlined; `operands` are its arguments' + * lanes. WGSL emits the builtin and reads its result's lanes; other backends run `body` and read each lane's `value`. + */ + | { readonly kind: "intrinsic"; readonly id: number; readonly name: string; readonly intrinsic: Intrinsic; readonly operands: readonly (readonly Expression[])[]; readonly body: readonly Statement[] }; +} + +/** + * Scalar locals, lookup handles, and the row body, in the order JS evaluates it. `names` are the locals' JS names, + * for readable generated code; GPU kernels carry them, and their intrinsic regions. + */ +export type Program = { + readonly locals: number; + /** Runs a list of rows, each with its parent's row for `up`, reading the parent's columns through `upLeaf`. */ + readonly up?: true; + readonly handles?: number; + readonly names?: readonly string[]; + /** Every intrinsic region's id is below it; absent when there are none. */ + readonly intrinsics?: number; + /** + * Accumulator locals, in `KernelDescription.reductions` order: each starts a call at its partial and ends it + * there, and the body folds values into it. + */ + readonly reductions?: readonly number[]; + readonly body: readonly Program.Statement[]; +}; + +/** + * How a call of a lookup through an index gives its key: per argument, a key part's value by the paths of its scalar + * leaves, in the order the call's values pass them, or for the last, a comparison's operators, each with its operand's. + */ +export type ProbeArgument = + | { readonly kind: "value"; readonly paths: readonly Path[] } + | { readonly kind: "comparison"; readonly operators: readonly { readonly operator: string; readonly paths: readonly Path[] }[] }; + +/** A call site of a lookup through an index: its lookup, an index into `KernelDescription.lookups`, and its arguments. */ +export type Probe = { readonly lookup: number; readonly arguments: readonly ProbeArgument[] }; + +/** What the compiler emits for one kernel system. */ +export type KernelDescription = { + readonly columns: readonly Column[]; + readonly shapes: readonly Shape[]; + readonly resources: readonly Key[]; + readonly lookups: readonly string[]; + /** Each call site of a lookup through an index. */ + readonly probes?: readonly Probe[]; + /** The scalar leaves bodies read of rows lookups through indexes gave: lookup, `read` index and path. */ + readonly rowLeaves?: readonly (readonly [number, number, Path])[]; + /** Whether it reads `up`, and the scalar leaves of the parent's components it reads. */ + readonly up?: boolean; + readonly upColumns?: readonly Column[]; + /** Whether it runs a list of rows in an index's order, as `up`'s rows run, without parents. */ + readonly ordered?: boolean; + readonly keys: readonly Key[]; + /** The components of each list the kernel spawns, as indexes into `keys`, in the order its spawner takes values. */ + readonly spawns: readonly (readonly number[])[]; + /** Indexes into `keys` of the components a command or spawn gives a tuple or struct, which needs a linear layout. */ + readonly structuredValues: readonly number[]; + /** A module exporting `create`, importable from the main thread and from workers. */ + readonly url: string; + /** The same body for WASM, or `null` when it uses what WASM kernels don't support: commands, or non-scalar values. */ + readonly program: Program | null; + /** The leaves of resources the kernel reduces into, one partial each, with their operators. */ + readonly reductions: readonly Reduced[]; + /** The leaves of each value contributed whole, by resource. Registration checks they are all of the schema's leaves. */ + readonly reducedShapes: readonly Shape[]; +}; + +/** What a kernel module at `KernelDescription.url` exports. */ +export type KernelModule = { + /** + * Called once per registration. Per column `k`: `booleans[k]` is true when it stores booleans as 0 or 1, so reads convert them; + * a row's element is at `row * strides[k] + offsets[k]`, in elements of the column's array. + */ + readonly create: (booleans: readonly boolean[], strides: readonly number[], offsets: readonly number[], upBooleans?: readonly boolean[], upStrides?: readonly number[], upOffsets?: readonly number[]) => Kernel; +}; diff --git a/packages/ecs/src/implementation/world/kernels-csp.test.ts b/packages/ecs/src/implementation/world/kernels-csp.test.ts new file mode 100644 index 000000000..1c08d64f1 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernels-csp.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +const value = Symbol("kernelsCspTest/value"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + } +} + +Component.register(value, { type: { type: "number", precision: 1 } }); + +const seed = System.create({ name: "kernelsCspTest/seed" }, ({ commands }) => { + for (let i = 0; i < 10; i++) commands.spawn([value])({ [value]: i }); +}); + +const double = System.create({ name: "kernelsCspTest/double", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] * 2; +}); + +describe("kernels where WASM can't be compiled", () => { + it("run as JS", async () => { + const compile = WebAssembly.compile; + // A content security policy without 'wasm-unsafe-eval' makes compiling throw. + WebAssembly.compile = () => Promise.reject(new WebAssembly.CompileError("blocked")); + try { + const world = createWorld({ [workers]: 0 }); + await world.run(seed); + await world.run(double); + expect(await readEach(world, await select(world, { required: [value] }), value)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 16, 18]); + await world.dispose(); + } + finally { + WebAssembly.compile = compile; + } + }); +}); diff --git a/packages/ecs/src/implementation/world/kernels.test.ts b/packages/ecs/src/implementation/world/kernels.test.ts new file mode 100644 index 000000000..abddaf9d4 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernels.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import { World } from "../../interface/world/world.js"; +import { definitionOf } from "../../interface/registry/index.js"; +import { attachKernel } from "./attach-kernel.js"; +import { bindKernel } from "./kernels.js"; +import type { KernelDescription } from "./kernel.js"; +import { createState } from "./state.js"; + +const value = Symbol("kernelsTest/value"); +const other = Symbol("kernelsTest/other"); +const scale = Symbol("kernelsTest/scale"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [other]: { type: number }; + [label]: { type: string | null }; + } + export interface Resources { + [scale]: { type: number }; + } +} + +const label = Symbol("kernelsTest/label"); +Component.register(value, { type: { type: "number" } }); +Component.register(other, { type: { type: "number" } }); +Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); +Resource.register(scale, { type: { type: "number", default: 1 } }); + +/** Reads `value` and `other`, writes nothing, reads no resources. */ +const system = System.create({ name: "kernelsTest/read", kernel: "cpu", components: { read: [value, other] } }, row => { + if (row[value] > row[other]) return; +}); + +const spawn = System.create({ name: "kernelsTest/spawn" }, ({ commands }) => { commands.spawn([value, other])({ [value]: 3, [other]: 0 }); }); +const double = System.create({ name: "kernelsTest/double", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] * 2; +}); + +const describing = (overrides: Partial): KernelDescription => + ({ columns: [], shapes: [], resources: [], lookups: [], keys: [], spawns: [], structuredValues: [], url: "unused", program: null, reductions: [], reducedShapes: [], ...overrides }); + +const bind = (overrides: Partial): void => { + const definition = definitionOf(attachKernel(system, describing(overrides))); + if (definition === undefined || definition.kind === "composite") throw new Error("not a leaf"); + bindKernel(createState(), definition, describing(overrides)); +}; + +describe("bindKernel", () => { + it("rejects a kernel reading a resource the system doesn't declare", () => { + expect(() => bind({ resources: [scale] })).toThrow('reads resource "kernelsTest/scale"'); + }); + it("rejects a changed column for a component the system only reads", () => { + expect(() => bind({ columns: [{ kind: "changed", component: value }] })).toThrow("isn't in its components' write"); + }); + it("rejects a whole-value write of a component the system only reads", () => { + expect(() => bind({ shapes: [{ component: value, leaves: [[]] }] })).toThrow("isn't in its components' write"); + }); + it("rejects a spawn list naming a component twice", () => { + expect(() => bind({ keys: [value, other], spawns: [[0, 1], [1, 0, 1]] })).toThrow('spawns a list naming "kernelsTest/other" twice'); + }); +}); + +describe("attachKernel", () => { + it("rejects at registration a spawn site naming a component twice", () => { + expect(() => attachKernel(system, describing({ keys: [value, other], spawns: [[1, 0, 1]] }))).toThrow('Kernel "kernelsTest/read": spawns a list naming "kernelsTest/other" twice.'); + }); + it("rejects at registration a tuple or struct given to a component without a linear layout", () => { + expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [1] }))).toThrow('Kernel "kernelsTest/read": gives "kernelsTest/label" a tuple or struct, which only a component stored in linear memory takes.'); + expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [0] }))).not.toThrow(); + }); + it("rejects at registration a spawn site naming a component outside its keys", () => { + expect(() => attachKernel(system, describing({ keys: [value], spawns: [[0, 1]] }))).toThrow('Kernel "kernelsTest/read": spawns a list naming component 1 of its 1 keys.'); + }); +}); diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts new file mode 100644 index 000000000..a760b4388 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -0,0 +1,329 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import type { Archetype } from './archetype.js'; +import type { Kernel, KernelDescription, KernelModule } from './kernel.js'; +import { layoutOf, samePath, type Layout, type TypedArray } from '../../interface/registry/layout.js'; +import type { LookupSetup, Source , KernelSetup } from './protocol.js'; +import { createArrayLookup } from './rows.js'; +import type { Since } from './index-lookups.js'; +import { commandIdOf, idOf, type Key, type State } from './state.js'; +import { generateWasm, instantiateWasm, type Type, type WasmColumn, type WasmKernel, type WasmLookup, type WasmRun, type WasmScalar } from './wasm.js'; +import { createDirectory, lookupParameters, type Directory } from './directories.js'; +import { generateWgsl, type WgslKernel } from './wgsl.js'; +import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import type { WorldIndex } from './indexes.js'; +import { kernelIndexesOf, refreshSource, writeIndexParameters, type KernelIndex } from './kernel-indexes.js'; +import { createIndexReader, createIndexSource, type IndexReaderSetup } from './index-readers.js'; + +/** A kernel without lookups passes no lookup parameters; read-only, so one is shared. */ +const noParameters: readonly number[] = []; + +export type BoundKernel = { + readonly description: KernelDescription; + /** Workers' index for it: one per kernel's code, its module's url, so new values of one kernel share it. */ + readonly index: number; + /** Its lookups' directories, which the world keeps current until they're released. */ + readonly directories: readonly Directory[]; + /** By lookup: a function by entity, or for a lookup through an index its reader, which `refreshIndexes` points at the index. */ + readonly lookups: readonly unknown[]; + /** By lookup, its index for a lookup through one, else null. */ + readonly indexes: readonly (KernelIndex | null)[]; + /** Points the JS readers at their indexes as they are now, and returns the WASM parameters of every lookup through one. */ + readonly refreshIndexes: (since: number) => readonly number[]; + /** What a worker needs to run the kernel, with component ids in place of keys; replaced by each load. */ + setup: { + readonly sources: readonly Source[]; + readonly booleans: readonly boolean[]; + readonly strides: readonly number[]; + readonly offsets: readonly number[]; + readonly lookups: readonly LookupSetup[]; + /** By lookup through an index: its lookup, how its JS reader reads it, and how many components it watches. */ + readonly indexes: readonly { readonly lookup: number; readonly reader: IndexReaderSetup; readonly watched: number }[]; + readonly keys: readonly number[]; + readonly starts: readonly unknown[]; + /** The component ids of each list the kernel spawns, in the order its spawner takes values. */ + readonly spawns: readonly (readonly number[])[]; + /** Tick columns whose stamps nothing observes. */ + readonly muted: readonly boolean[]; + readonly up: KernelSetup["up"]; + }; + /** The kernel's columns for an archetype, rebuilt after the archetype grows. */ + readonly columnsOf: (archetype: Archetype) => readonly TypedArray[]; + /** A parent archetype's columns of the parent's components the kernel reads through `up`. */ + readonly upColumnsOf: (archetype: Archetype) => readonly TypedArray[]; + /** Imports the kernel module and compiles its WASM for the components observed now; `kernel` throws until it resolves. */ + readonly load: () => Promise; + /** + * Whether it needs loading: it never was, or a component whose stamps it muted is observed now. One no longer + * observed keeps its stamps until it loads again for another reason, so a filter coming and going reloads nothing. + */ + readonly stale: () => boolean; + /** Whether workers have an older load than the main thread; `loadedOnWorkers` records that they caught up. */ + readonly staleOnWorkers: () => boolean; + readonly loadedOnWorkers: () => void; + /** Records that workers hold no load, as a new pool's don't. */ + readonly unloadedOnWorkers: () => void; + readonly kernel: () => Kernel; + /** The WASM version, when the kernel has a program and the world shares its memory; replaced by each load. */ + wasm: WasmKernel | null; + /** The compiled module and the main thread's instance, once loaded. */ + readonly compiled: () => { readonly module: WebAssembly.Module; readonly run: WasmRun } | null; + /** For a WASM kernel with lookups: the entity table and directories, passed ahead of the scalars. */ + readonly lookupParameters: () => readonly number[]; + /** The kernel as a compute shader for the components observed at the last load, or why it can't run on the GPU. */ + readonly gpu: () => WgslKernel | string; +}; + +/** Whether a scalar leaf at `path` is a boolean, a number, or not a scalar leaf. */ +const leafType = (layout: Layout, path: readonly (number | string)[]): Type | null => { + if (layout.kind !== "linear") return null; + const leaf = layout.leaves.find(candidate => samePath(candidate.path, path)); + return leaf === undefined ? null : leaf.scalar === "boolean" ? "boolean" : "number"; +}; + +/** + * Binds a kernel to the world's components and layouts. Registering the kernel checked it against its declaration and + * schemas (`checkKernel`), so a mismatch here is an invariant broken. + */ +export const bindKernel = (state: State, leaf: Leaf, description: KernelDescription, byName: ReadonlyMap = new Map(), since: Since = { value: 0 }): BoundKernel => { + const { lookup, components: query } = leaf.declaration; + const fail = (message: string): never => { throw new Error(`Kernel "${labelOfItem(leaf)}": ${message}`); }; + const booleans: boolean[] = []; + // Per column, in elements of its view: entity and tick columns are dense. + const strides: number[] = []; + const offsets: number[] = []; + const wasmColumns: WasmColumn[] = []; + const dense = (): void => { + booleans.push(false); + strides.push(1); + offsets.push(0); + wasmColumns.push({ view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }); + }; + const sources: Source[] = description.columns.map(column => { + if (column.kind === "entity") { + dense(); + return column; + } + const id = idOf(state, column.component); + if (column.kind === "changed" || column.kind === "chunks") { + dense(); + return { kind: column.kind, id, leaf: 0 }; + } + const layout = state.layouts[id]; + if (layout.kind !== "linear") return fail(`"${labelOf(column.component)}" isn't stored in linear memory.`); + const index = layout.leaves.findIndex(leaf => samePath(leaf.path, column.path)); + if (index < 0) return fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + const found = layout.leaves[index]; + booleans.push(found.scalar === "boolean"); + strides.push(found.unitStride); + offsets.push(found.unitOffset); + wasmColumns.push({ view: found.view, stride: layout.stride, offset: found.offset, boolean: found.scalar === "boolean", component: id }); + return { kind: "field", id, leaf: index }; + }); + const declaredLookups = description.lookups.map(lookupName => lookup?.[lookupName] ?? fail(`unknown lookup "${lookupName}".`)); + const { up } = leaf.declaration; + const indexes = kernelIndexesOf(state, description, declaredLookups, byName, fail); + /** Each lookup through an index's reader for the JS kernel, over its index's own arrays. */ + const readers = indexes.map(bound => bound === null ? null : { bound, reader: createIndexReader(bound.reader, createIndexSource(bound.reader.kind), id => { + const archetype = state.archetypes[id]; + return archetype === undefined ? undefined : { version: archetype.version, entities: archetype.entities, column: (component, view) => archetype.storageById[component]?.views[view] }; + }) }); + // The runner passes `up`'s lookup after these, since it records each row's parent as it orders the rows. + const lookups: readonly unknown[] = declaredLookups.map((declared, k): unknown => readers[k]?.reader ?? createArrayLookup(state, declared, since)); + const indexParameters: number[] = []; + const refreshIndexes = (since: number): readonly number[] => { + let at = 0; + for (const found of readers) { + if (found === null) continue; + refreshSource(found.reader.source, found.bound, since); + at = writeIndexParameters(found.bound, since, indexParameters, at); + } + indexParameters.length = at; + return indexParameters; + }; + const ids = (keys: readonly Key[] | undefined): number[] => (keys ?? []).map(key => idOf(state, key)); + const keys = description.keys.map(key => commandIdOf(state, key)); + const spawns = description.spawns.map(list => list.map(k => keys[k])); + const setup = { + sources, + booleans, + strides, + offsets, + lookups: declaredLookups.map((declared): LookupSetup => ({ + read: ids(declared.read), has: ids("has" in declared ? declared.has : undefined), not: ids("not" in declared ? declared.not : undefined), + changed: "index" in declared || declared.changed === undefined ? null : ids(declared.changed), + })), + indexes: indexes.flatMap(bound => bound === null ? [] : [{ lookup: bound.lookup, reader: bound.reader, watched: bound.watched.length }]), + keys, + starts: keys.map(id => state.starts[id]), + spawns, + }; + /** Every component its lookups by entity watch. */ + const watched = [...new Set(setup.lookups.flatMap(lookupSetup => lookupSetup.changed ?? []))]; + /** The one parameter a JS kernel watching without directories takes. */ + const sinceParameters = [0]; + /** + * The tick its lookups by entity compare with, passed after the lookup parameters: its `lastRun`, or -1 once a + * GPU kernel stamped a whole column of one it watches since, so every row counts as changed. + */ + const lookupsSince = (): number => { + const { value } = since; + if (state.gpu === null) return value; + for (const archetype of state.archetypes) { + for (let k = 0; k < watched.length; k++) { + const storage = archetype.storageById[watched[k]]; + if (storage !== undefined && storage.ticked && storage.columnChanged > value) return -1; + } + } + return value; + }; + const cache = new Map(); + const columnsOf = (archetype: Archetype): readonly TypedArray[] => { + const cached = cache.get(archetype); + if (cached !== undefined && cached.version === archetype.version) return cached.columns; + const columns = sources.map(source => { + if (source.kind === "entity") return archetype.entities; + const storage = archetype.storageOf.get(source.id); + if (storage === undefined) return fail(`column of "${labelOf(state.keys[source.id])}" isn't in its components.`); + if (source.kind === "changed") return storage.changed; + if (source.kind === "chunks") return storage.chunkChanged; + const layout = state.layouts[source.id]; + return layout.kind === "linear" ? storage.views[layout.leaves[source.leaf].view] : fail("a field column isn't linear."); + }); + cache.set(archetype, { version: archetype.version, columns }); + return columns; + }; + // A leaf's stride and offset depend only on its component's layout, so they're the same in every parent archetype. + const upSources = (description.upColumns ?? []).map(column => { + if (column.kind !== "field") return fail("reads a column through up that isn't a field."); + const id = idOf(state, column.component); + const layout = state.layouts[id]; + if (layout.kind !== "linear") return fail(`up reads "${labelOf(column.component)}", which isn't stored in linear memory.`); + const found = layout.leaves.find(candidate => samePath(candidate.path, column.path)); + if (found === undefined) return fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + return { id, leaf: found }; + }); + const upSetup: KernelSetup["up"] = { + booleans: upSources.map(source => source.leaf.scalar === "boolean"), + strides: upSources.map(source => source.leaf.unitStride), + offsets: upSources.map(source => source.leaf.unitOffset), + }; + const upCache = new Map(); + const upColumnsOf = (archetype: Archetype): readonly TypedArray[] => { + const cached = upCache.get(archetype); + if (cached !== undefined && cached.version === archetype.version) return cached.columns; + const columns = upSources.map(source => { + const storage = archetype.storageById[source.id]; + return storage === undefined ? fail(`a parent's archetype has no "${labelOf(state.keys[source.id])}".`) : storage.views[source.leaf.view]; + }); + upCache.set(archetype, { version: archetype.version, columns }); + return columns; + }; + const argsLayout = leaf.declaration.input === undefined ? null : layoutOf(leaf.declaration.input); + const scalarType = (scalar: WasmScalar): Type | null => scalar.source === "args" + ? argsLayout === null ? null : leafType(argsLayout, scalar.path) + : scalar.index < description.resources.length ? leafType(state.layouts[idOf(state, description.resources[scalar.index])], scalar.path) : null; + const useWasm = state.wasm && state.memory.shared && description.program !== null; + // A lookup through an index's directory also holds each archetype's entities, which its rows' `entity` reads. + const directories = useWasm ? setup.lookups.map((lookupSetup, k) => createDirectory(state, lookupSetup.read, lookupSetup.has, lookupSetup.not, indexes[k] !== null, lookupSetup.changed ?? [])) : []; + const wasmLookups: WasmLookup[] = setup.lookups.map((lookupSetup, k) => ({ + reads: lookupSetup.read.length + (indexes[k] === null ? 0 : 1), + watched: lookupSetup.changed?.length ?? 0, + leaf: (read, path) => { + const component = lookupSetup.read[read]; + const layout = component === undefined ? undefined : state.layouts[component]; + if (layout?.kind !== "linear") return null; + const found = layout.leaves.find(leaf => samePath(leaf.path, path)); + return found === undefined ? null : { view: found.view, stride: layout.stride, offset: found.offset, boolean: found.scalar === "boolean" }; + }, + })); + /** + * Changed and chunk columns of components no registered system filters on are muted: nothing reads those ticks, + * so the WASM drops their stamps and the JS kernel stamps one dummy element. Chunk columns of components held on + * the GPU stay, since uploads read them. + */ + const stamped = (source: Extract): boolean => state.observed.has(source.id) || (source.kind === "chunks" && state.resident.has(source.id)); + const mutedNow = (): boolean[] => sources.map(source => (source.kind === "changed" || source.kind === "chunks") && !stamped(source)); + const upWasmColumns: WasmColumn[] = upSources.map(source => { + const layout = state.layouts[source.id]; + return { view: source.leaf.view, stride: layout.kind === "linear" ? layout.stride : 0, offset: source.leaf.offset, boolean: source.leaf.scalar === "boolean", component: source.id }; + }); + // An `up` kernel's runner passes only the rows that pass its filters. + const filterCount = up !== undefined ? 0 : (query?.added?.length ?? 0) + (query?.changed?.length ?? 0); + const generate = (muted: readonly boolean[]): WasmKernel | null => useWasm && description.program !== null + ? generateWasm(description.program, wasmColumns.map((column, k) => muted[k] ? { ...column, muted: true } : column), scalarType, state.simd, filterCount, wasmLookups, upWasmColumns, + description.reductions.map(reduction => reduction.operator), indexes.map(bound => bound?.wasm ?? null), (description.probes ?? []).map(probe => probe.lookup)) + : null; + let muted = mutedNow(); + let index = state.kernelIndexes.get(description.url); + if (index === undefined) state.kernelIndexes.set(description.url, index = state.kernelIndexes.size); + let loaded: Kernel | null = null; + let loadedFor: string | null = null; + let workersFor: string | null = null; + let compiled: { readonly module: WebAssembly.Module; readonly run: WasmRun } | null = null; + const bound: BoundKernel = { + description, + index, + directories, + lookups, + indexes, + refreshIndexes, + setup: { ...setup, muted, up: upSetup }, + columnsOf: archetype => { + const columns = columnsOf(archetype); + return muted.some(Boolean) ? columns.map((column, k) => muted[k] ? mutedColumn : column) : columns; + }, + upColumnsOf, + wasm: generate(muted), + compiled: () => compiled, + kernel: () => loaded ?? fail("its module isn't loaded."), + stale: () => { + if (loadedFor === null) return true; + for (let k = 0; k < sources.length; k++) { + const source = sources[k]; + if (muted[k] && source.kind !== "entity" && stamped(source)) return true; + } + return false; + }, + staleOnWorkers: () => workersFor !== loadedFor, + loadedOnWorkers: () => { workersFor = loadedFor; }, + unloadedOnWorkers: () => { workersFor = null; }, + load: async () => { + muted = mutedNow(); + bound.wasm = generate(muted); + bound.setup = { ...setup, muted, up: upSetup }; + loadedFor = muted.join(); + const module: KernelModule = await import(/* @vite-ignore */ description.url); + loaded = module.create(booleans, strides.map((stride, k) => muted[k] ? 0 : stride), offsets.map((offset, k) => muted[k] ? 0 : offset), upSetup.booleans, upSetup.strides, upSetup.offsets); + compiled = null; + if (bound.wasm === null) return; + try { + const wasmModule = await WebAssembly.compile(bound.wasm.bytes); + compiled = { module: wasmModule, run: await instantiateWasm(wasmModule, state.memory.memory, bound.wasm.imports) }; + } + catch (error) { + // A content security policy without 'wasm-unsafe-eval' forbids compiling; the JS kernel runs instead. + if (!(error instanceof WebAssembly.CompileError)) throw error; + bound.wasm = null; + } + }, + lookupParameters: watched.length === 0 + ? () => directories.length === 0 ? noParameters : lookupParameters(state, directories) + : () => { + if (directories.length === 0) { + sinceParameters[0] = lookupsSince(); + return sinceParameters; + } + const parameters = lookupParameters(state, directories); + parameters.push(lookupsSince()); + return parameters; + }, + gpu: () => description.program === null + ? "it issues commands or uses values that aren't numbers" + : description.lookups.length > 0 ? "it uses lookups" + : generateWgsl(description.program, wasmColumns.map((column, k) => muted[k] ? { ...column, muted: true } : column), scalarType, upWasmColumns), + }; + return bound; +}; + +/** The one element a muted tick column's stores land on. */ +const mutedColumn = new Float64Array(1); diff --git a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts new file mode 100644 index 000000000..5390c7169 --- /dev/null +++ b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { workers } from "../../interface/resources/workers.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; + +const value = Symbol("layoutDirectoryTest/value"); +const target = Symbol("layoutDirectoryTest/target"); +const seen = Symbol("layoutDirectoryTest/seen"); +const child = Symbol("layoutDirectoryTest/child"); +const tag0 = Symbol("layoutDirectoryTest/tag0"); +const tag1 = Symbol("layoutDirectoryTest/tag1"); +const tag2 = Symbol("layoutDirectoryTest/tag2"); +const tag3 = Symbol("layoutDirectoryTest/tag3"); +const tag4 = Symbol("layoutDirectoryTest/tag4"); +const tag5 = Symbol("layoutDirectoryTest/tag5"); +const tag6 = Symbol("layoutDirectoryTest/tag6"); +const tags = [tag0, tag1, tag2, tag3, tag4, tag5, tag6] as const; + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [target]: { type: Entity }; + [seen]: { type: number }; + [child]: { type: true }; + [tag0]: { type: true }; + [tag1]: { type: true }; + [tag2]: { type: true }; + [tag3]: { type: true }; + [tag4]: { type: true }; + [tag5]: { type: true }; + [tag6]: { type: true }; + } +} + +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(target, { type: Entity.schema }); +Component.register(seen, { type: { type: "number", precision: 1 } }); +Component.register(child, { type: { const: true } }); +Component.register(tag0, { type: { const: true } }); +Component.register(tag1, { type: { const: true } }); +Component.register(tag2, { type: { const: true } }); +Component.register(tag3, { type: { const: true } }); +Component.register(tag4, { type: { const: true } }); +Component.register(tag5, { type: { const: true } }); +Component.register(tag6, { type: { const: true } }); + +/** The component lists of 100 archetypes: `value` with each of the first 100 combinations of the tags. */ +const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); + +/** Spawns `count` entities into each of the 100 archetypes. */ +const fill = System.create({ name: "layoutDirectoryTest/fill", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); +}); + +const bump = System.create({ name: "layoutDirectoryTest/bump", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); + +const total = { sum: 0, count: 0 }; +/** Sums every value on the world's thread, so it reads what workers wrote. */ +const sum = System.create({ name: "layoutDirectoryTest/sum", components: { read: [value] } }, row => { + total.sum += row[value]; + total.count++; +}); + +/** Entities whose values the lookups read, recorded as they spawn. */ +const spawned: Entity[] = []; +const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) => { + for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); + commands.spawn([value])({ [value]: -1 }); +}); +const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); +}); +/** Aims each looker at one of the last spawned entities. */ +let aimed = 0; +const aim = System.create({ name: "layoutDirectoryTest/aim", components: { write: [target] } }, row => { + row[target] = spawned[spawned.length - 1 - aimed++]; +}); +const despawnValues = System.create({ name: "layoutDirectoryTest/despawnValues", components: { has: [value], not: [child] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); +/** Lookups from workers. */ +const look = System.create({ + name: "layoutDirectoryTest/look", + kernel: "cpu", + components: { read: [target], write: [seen] }, + lookup: { targets: { read: [value] } }, +}, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + row[seen] = other === null ? -1 : other[value]; +}); +/** Spawns from workers: a child per looker. */ +const breed = System.create({ name: "layoutDirectoryTest/breed", kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { + commands.spawn([value, child])({ [value]: row[seen] }); +}); + +const disposals: World[] = []; +afterEach(async () => { + for (const world of disposals.splice(0)) await world.dispose(); +}); + +/** Counts the layout messages posted to workers while `during` runs. */ +const layoutMessages = async (during: () => Promise): Promise => { + let messages = 0; + const original = Worker.prototype.postMessage; + Worker.prototype.postMessage = function (this: Worker, message: unknown, transfer?: Transferable[] | StructuredSerializeOptions) { + if (typeof message === "object" && message !== null && Reflect.get(message, "kind") === "layout") messages++; + original.call(this, message, transfer as StructuredSerializeOptions); + }; + try { + await during(); + } + finally { + Worker.prototype.postMessage = original; + } + return messages; +}; + +const totals = async (world: World): Promise<{ sum: number; count: number }> => { + total.sum = total.count = 0; + await world.run(sum); + return { ...total }; +}; + +describe("the layout directory", () => { + for (const wasm of [true, false]) { + it(`lets workers' ${wasm ? "WASM" : "JS"} kernels see every row of 100 archetypes grown across sync points, with no layout message after the one creating them`, async () => { + const world = createWorld({ [workers]: 3 }, { minimumChunk: 16, wasm }); + disposals.push(world); + await world.run(fill, { count: 1 }); + await world.run(bump); + const counts = [16, 32, 64, 128, 256]; + const messages = await layoutMessages(async () => { + for (const count of counts) { + await world.run(fill, { count }); + await world.run(bump); + } + }); + expect(messages).toBe(0); + // Each entity was bumped once per round from the one it spawned in. + const rounds = counts.length; + const expected = 100 * (rounds + 1) + counts.reduce((sum, count, r) => sum + 100 * count * (rounds - r), 0); + expect(await totals(world)).toEqual({ sum: expected, count: 100 * (1 + counts.reduce((a, b) => a + b, 0)) }); + }); + } + + it("lets workers' lookups and spawns use the entity table once it grows between runs, with no message", async () => { + spawned.length = 0; + aimed = 0; + const world = createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm: false }); + disposals.push(world); + await world.run(make); + await world.run(look); + await world.run(breed); + const lookers = await select(world, { required: [seen] }); + const messages = await layoutMessages(async () => { + // The table grows from 64 entities to past 10,000. + await world.run(spawnMany, { count: 10_000 }); + await world.run(aim); + await world.run(look); + expect(new Set(await readEach(world, lookers, seen))).toEqual(new Set(Array.from({ length: 64 }, (_, i) => 9_999 - i))); + // The free list grows past 10,000 indexes, which workers' spawns then reuse. + await world.run(despawnValues); + await world.run(breed); + }); + expect(messages).toBe(0); + const children = await select(world, { required: [child] }); + expect(new Set(children).size).toBe(128); + expect((await readEach(world, children, value)).filter(each => each === -1)).toHaveLength(64); + expect((await readEach(world, children, value)).filter(each => each !== null && each >= 9_936)).toHaveLength(64); + }); + + for (const wasm of [true, false]) { + it(`lets workers' ${wasm ? "WASM" : "JS"} kernels rebuild their views once memory grows between runs`, async () => { + const world = createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm }); + disposals.push(world); + await world.run(fill, { count: 1 }); + await world.run(bump); + let grows = 0; + const original = WebAssembly.Memory.prototype.grow; + WebAssembly.Memory.prototype.grow = function (this: WebAssembly.Memory, delta: number) { + grows++; + return original.call(this, delta); + }; + let messages = 0; + try { + messages = await layoutMessages(async () => { + await world.run(fill, { count: 2_000 }); + await world.run(bump); + }); + } + finally { + WebAssembly.Memory.prototype.grow = original; + } + expect(grows).toBeGreaterThan(0); + expect(messages).toBe(0); + expect(await totals(world)).toEqual({ sum: 100 * 2 + 100 * 2_000, count: 100 * 2_001 }); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/layout-directory.cases.ts b/packages/ecs/src/implementation/world/layout-directory.cases.ts new file mode 100644 index 000000000..bde64330b --- /dev/null +++ b/packages/ecs/src/implementation/world/layout-directory.cases.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./layout-directory.fixture.js"; + +/** Workers find layouts in the directory, with no message once it changes: the cases, over the fixture built or from source. */ +export const layoutDirectoryCases = (load: () => Promise) => describe("the layout directory", () => { + for (const wasm of [true, false]) { + it(`lets workers' ${wasm ? "WASM" : "JS"} kernels see every row of 100 archetypes grown across sync points, with no layout message after the one creating them`, async () => { + const fixture = await load(); + const { messages, totals } = await fixture.grown(wasm); + expect(messages).toBe(0); + // Each entity was bumped once per round from the one it spawned in. + const rounds = fixture.rounds.length; + const expected = 100 * (rounds + 1) + fixture.rounds.reduce((sum, count, r) => sum + 100 * count * (rounds - r), 0); + expect(totals).toEqual({ sum: expected, count: 100 * (1 + fixture.rounds.reduce((a, b) => a + b, 0)) }); + }); + } + + it("lets workers' lookups and spawns use the entity table once it grows between runs, with no message", async () => { + const fixture = await load(); + const { messages, seen, children, values } = await fixture.tableGrown(); + expect(new Set(seen)).toEqual(new Set(Array.from({ length: 64 }, (_, i) => 9_999 - i))); + expect(messages).toBe(0); + expect(children).toBe(128); + expect(values.filter(each => each === -1)).toHaveLength(64); + expect(values.filter(each => each !== null && each >= 9_936)).toHaveLength(64); + }); + + for (const wasm of [true, false]) { + it(`lets workers' ${wasm ? "WASM" : "JS"} kernels rebuild their views once memory grows between runs`, async () => { + const fixture = await load(); + const { grows, messages, totals } = await fixture.memoryGrown(wasm); + expect(grows).toBeGreaterThan(0); + expect(messages).toBe(0); + expect(totals).toEqual({ sum: 100 * 2 + 100 * 2_000, count: 100 * 2_001 }); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/layout-directory.fixture.ts b/packages/ecs/src/implementation/world/layout-directory.fixture.ts new file mode 100644 index 000000000..eb48225fb --- /dev/null +++ b/packages/ecs/src/implementation/world/layout-directory.fixture.ts @@ -0,0 +1,186 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { workers } from "../../interface/resources/workers.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { countingWorkers } from "./platforms.test-helper.js"; + +/** Scenarios of workers reading layouts from the directory, run built in node and from source in chromium. */ + +const value = Symbol("layoutDirectoryTest/value"); +const target = Symbol("layoutDirectoryTest/target"); +const seen = Symbol("layoutDirectoryTest/seen"); +const child = Symbol("layoutDirectoryTest/child"); +const tag0 = Symbol("layoutDirectoryTest/tag0"); +const tag1 = Symbol("layoutDirectoryTest/tag1"); +const tag2 = Symbol("layoutDirectoryTest/tag2"); +const tag3 = Symbol("layoutDirectoryTest/tag3"); +const tag4 = Symbol("layoutDirectoryTest/tag4"); +const tag5 = Symbol("layoutDirectoryTest/tag5"); +const tag6 = Symbol("layoutDirectoryTest/tag6"); +const tags = [tag0, tag1, tag2, tag3, tag4, tag5, tag6] as const; + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [target]: { type: Entity }; + [seen]: { type: number }; + [child]: { type: true }; + [tag0]: { type: true }; + [tag1]: { type: true }; + [tag2]: { type: true }; + [tag3]: { type: true }; + [tag4]: { type: true }; + [tag5]: { type: true }; + [tag6]: { type: true }; + } +} + +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(target, { type: Entity.schema }); +Component.register(seen, { type: { type: "number", precision: 1 } }); +Component.register(child, { type: { const: true } }); +Component.register(tag0, { type: { const: true } }); +Component.register(tag1, { type: { const: true } }); +Component.register(tag2, { type: { const: true } }); +Component.register(tag3, { type: { const: true } }); +Component.register(tag4, { type: { const: true } }); +Component.register(tag5, { type: { const: true } }); +Component.register(tag6, { type: { const: true } }); + +/** The component lists of 100 archetypes: `value` with each of the first 100 combinations of the tags. */ +const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); + +/** Spawns `count` entities into each of the 100 archetypes. */ +const fill = System.create({ name: "layoutDirectoryTest/fill", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); +}); + +const bump = System.create({ name: "layoutDirectoryTest/bump", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); + +const total = { sum: 0, count: 0 }; +/** Sums every value on the world's thread, so it reads what workers wrote. */ +const sum = System.create({ name: "layoutDirectoryTest/sum", components: { read: [value] } }, row => { + total.sum += row[value]; + total.count++; +}); + +/** Entities whose values the lookups read, recorded as they spawn. */ +const spawned: Entity[] = []; +const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) => { + for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); + commands.spawn([value])({ [value]: -1 }); +}); +const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); +}); +/** Aims each looker at one of the last spawned entities. */ +let aimed = 0; +const aim = System.create({ name: "layoutDirectoryTest/aim", components: { write: [target] } }, row => { + row[target] = spawned[spawned.length - 1 - aimed++]; +}); +const despawnValues = System.create({ name: "layoutDirectoryTest/despawnValues", components: { has: [value], not: [child] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); +/** Lookups from workers. */ +const look = System.create({ + name: "layoutDirectoryTest/look", + kernel: "cpu", + components: { read: [target], write: [seen] }, + lookup: { targets: { read: [value] } }, +}, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + row[seen] = other === null ? -1 : other[value]; +}); +/** Spawns from workers: a child per looker. */ +const breed = System.create({ name: "layoutDirectoryTest/breed", kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { + commands.spawn([value, child])({ [value]: row[seen] }); +}); + +/** Runs `use` with a world, then disposes it. */ +const using = async (world: World, use: (world: World) => Promise): Promise => { + try { + return await use(world); + } + finally { + await world.dispose(); + } +}; + +const totals = async (world: World): Promise<{ sum: number; count: number }> => { + total.sum = total.count = 0; + await world.run(sum); + return { ...total }; +}; + +/** Rows that spawn into each of the 100 archetypes in each round after the first, which spawns 1. */ +export const rounds: readonly number[] = [16, 32, 64, 128, 256]; + +/** 100 archetypes grown over `rounds`, each round bumped by workers: layout messages after the first round, and the totals. */ +export const grown = async (wasm: boolean): Promise<{ readonly messages: number; readonly totals: { sum: number; count: number } }> => { + const counting = countingWorkers(); + return using(createWorld({ [workers]: 3 }, { minimumChunk: 16, wasm, pool: counting.platform }), async world => { + await world.run(fill, { count: 1 }); + await world.run(bump); + const before = counting.posted("layout"); + for (const count of rounds) { + await world.run(fill, { count }); + await world.run(bump); + } + return { messages: counting.posted("layout") - before, totals: await totals(world) }; + }); +}; + +/** + * Lookups and spawns from workers once the entity table grows from 64 entities past 10,000 and its free list past + * 10,000 indexes: layout messages meanwhile, what the lookers saw, and the children workers spawned. + */ +export const tableGrown = async (): Promise<{ readonly messages: number; readonly seen: readonly (number | null)[]; readonly children: number; readonly values: readonly (number | null)[] }> => { + spawned.length = 0; + aimed = 0; + const counting = countingWorkers(); + return using(createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm: false, pool: counting.platform }), async world => { + await world.run(make); + await world.run(look); + await world.run(breed); + const lookers = await select(world, { required: [seen] }); + const before = counting.posted("layout"); + await world.run(spawnMany, { count: 10_000 }); + await world.run(aim); + await world.run(look); + const seenValues = await readEach(world, lookers, seen); + await world.run(despawnValues); + await world.run(breed); + const messages = counting.posted("layout") - before; + const children = await select(world, { required: [child] }); + return { messages, seen: seenValues, children: new Set(children).size, values: await readEach(world, children, value) }; + }); +}; + +/** Workers' kernels once memory grows between runs: how often it grew, layout messages meanwhile, and the totals. */ +export const memoryGrown = async (wasm: boolean): Promise<{ readonly grows: number; readonly messages: number; readonly totals: { sum: number; count: number } }> => { + const counting = countingWorkers(); + return using(createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm, pool: counting.platform }), async world => { + await world.run(fill, { count: 1 }); + await world.run(bump); + let grows = 0; + const original = WebAssembly.Memory.prototype.grow; + WebAssembly.Memory.prototype.grow = function (this: WebAssembly.Memory, delta: number) { + grows++; + return original.call(this, delta); + }; + const before = counting.posted("layout"); + try { + await world.run(fill, { count: 2_000 }); + await world.run(bump); + } + finally { + WebAssembly.Memory.prototype.grow = original; + } + return { grows, messages: counting.posted("layout") - before, totals: await totals(world) }; + }); +}; diff --git a/packages/ecs/src/implementation/world/layout-directory.ts b/packages/ecs/src/implementation/world/layout-directory.ts new file mode 100644 index 000000000..a3ac7cf87 --- /dev/null +++ b/packages/ecs/src/implementation/world/layout-directory.ts @@ -0,0 +1,230 @@ +import { allocateArray, growArray } from './arrays.js'; +import type { Archetype } from './archetype.js'; +import type { Entities } from './entities.js'; +import type { Memory } from './memory.js'; + +/** + * Where everything in the shared memory is, for other threads: a header at offset 0 that never moves, a table of + * archetype record offsets by archetype id, and a record per archetype. Every word is a `Uint32`, since memory reaches + * 4 GiB. The world's thread keeps its JS fields as the source of truth and writes records only at flushes, each + * record's version last, with `Atomics.store`; a reader checks a record's version before using what it cached. + * + * Header words: the memory's length in pages (`memory.ts`), the table's offset and length, the structure epoch, then + * the entity-table record: its `cursor` offset, which never moves, `free` offset and capacity, `archetype`, `row` and + * `generation` offsets and their capacity, and its version; then the resource entity's index plus one, 0 for none. The + * table's length is `cursor[2]`. + * + * The structure epoch is odd from the start of each sync point that applies anything until its directory writes at the + * end, and even otherwise. A thread reading entity locations outside a task reads while it's even and checks it again + * after, retrying on a change, and waits on it while it's odd, counted in the waiters word first; the world notifies + * waiting readers when it turns even. + */ +export const tableOffsetWord = 1; +export const tableLengthWord = 2; +export const structureWord = 3; +export const entitiesWord = 4; +export const cursorField = 0; +export const freeField = 1; +export const freeCapacityField = 2; +export const archetypeField = 3; +export const rowField = 4; +export const generationField = 5; +export const capacityField = 6; +export const entitiesVersionField = 7; +export const resourceEntityWord = 12; +/** Readers waiting on the structure epoch, so the world notifies only when one is. */ +export const structureWaitersWord = 13; + +/** + * An archetype record: its capacity, `entities` offset, storage count, version, flags and row count, then per storage + * its component number, flags, column, `added`, `changed`, `chunkAdded` and `chunkChanged` offsets, and chunk count. + * Offsets a storage doesn't have are 0. A record is allocated once, since an archetype's storages are fixed, and never + * moves. Its count is written at flushes while a joined thread may read it, for archetypes whose count changed. + */ +export const recordCapacity = 0; +export const recordEntities = 1; +export const recordStorages = 2; +export const recordVersion = 3; +export const recordFlags = 4; +export const recordCount = 5; +export const recordWords = 6; +/** Record flags: it's the resource archetype, of the entity resources live on. */ +export const resourceFlag = 1; +/** Record flags: it holds reports, which only queries that require a component see. */ +export const metaFlag = 2; +export const storageWords = 8; +export const storageComponent = 0; +export const storageFlags = 1; +export const storageColumn = 2; +export const storageAdded = 3; +export const storageChanged = 4; +export const storageChunkAdded = 5; +export const storageChunkChanged = 6; +export const storageChunks = 7; +/** + * Storage flags: it keeps ticks; it has a column in linear memory, a string's of handles included; its objects are a joined thread's, whose number is + * the flags from `threadShift` on, and its column holds each row's tag (`move-log.ts`). + */ +export const tickedFlag = 1; +export const linearFlag = 2; +export const remoteFlag = 4; +export const threadShift = 8; + +/** The world's side of the directory. */ +export type LayoutDirectory = { + readonly memory: Memory; + readonly header: Uint32Array; + /** The header as signed words, which `Atomics.notify` takes. */ + readonly words: Int32Array; + table: Uint32Array; + /** By archetype id, its record, once flushed. */ + readonly records: (Uint32Array | undefined)[]; + /** The entity table's version as last flushed. */ + entities: number; + /** Whether a joined thread may read entity locations outside a task, so sync points keep the structure epoch. */ + guarded: boolean; +}; + +const initialTable = 64; + +/** A directory for shared memory; without it, no other thread reads the memory, so there's none. */ +export const createLayoutDirectory = (memory: Memory): LayoutDirectory | null => { + if (!memory.shared) return null; + const table = allocateArray(memory, Uint32Array, initialTable).fill(0); + const { header } = memory; + header[tableOffsetWord] = table.byteOffset; + header[tableLengthWord] = 0; + return { memory, header, words: new Int32Array(header.buffer, header.byteOffset, header.length), table, records: [], entities: -1, guarded: false }; +}; + +const writeEntities = ({ header }: LayoutDirectory, entities: Entities): void => { + header[entitiesWord + cursorField] = entities.cursor.byteOffset; + header[entitiesWord + freeField] = entities.free.byteOffset; + header[entitiesWord + freeCapacityField] = entities.free.length; + header[entitiesWord + archetypeField] = entities.archetype.byteOffset; + header[entitiesWord + rowField] = entities.row.byteOffset; + header[entitiesWord + generationField] = entities.generation.byteOffset; + header[entitiesWord + capacityField] = entities.archetype.length; + Atomics.store(header, entitiesWord + entitiesVersionField, entities.version); +}; + +/** The record for an archetype, allocated and entered in the table the first time it's flushed. */ +const recordOf = (directory: LayoutDirectory, archetype: Archetype): Uint32Array => { + const existing = directory.records[archetype.id]; + if (existing !== undefined) return existing; + const { memory, header } = directory; + if (archetype.id >= directory.table.length) { + const used = directory.table.length; + let length = used * 2; + while (length <= archetype.id) length *= 2; + directory.table = growArray(memory, directory.table, Uint32Array, length, used).fill(0, used); + header[tableOffsetWord] = directory.table.byteOffset; + } + const record = allocateArray(memory, Uint32Array, recordWords + archetype.storages.length * storageWords); + directory.records[archetype.id] = record; + return record; +}; + +const writeArchetype = (directory: LayoutDirectory, archetype: Archetype): void => { + const fresh = directory.records[archetype.id] === undefined; + const record = recordOf(directory, archetype); + record[recordCapacity] = archetype.capacity; + record[recordEntities] = archetype.entities.byteOffset; + record[recordStorages] = archetype.storages.length; + record[recordFlags] = (archetype.resource ? resourceFlag : 0) | (archetype.meta ? metaFlag : 0); + record[recordCount] = archetype.count; + for (let s = 0; s < archetype.storages.length; s++) { + const storage = archetype.storages[s]; + const base = recordWords + s * storageWords; + // A string's column of handles is in linear memory too, its records with it. + const linear = storage.layout.kind === "linear" || storage.layout.kind === "string"; + const { remote } = storage; + record[base + storageComponent] = storage.component; + record[base + storageFlags] = (storage.ticked ? tickedFlag : 0) | (linear ? linearFlag : 0) | (remote === null ? 0 : remoteFlag | (remote.thread << threadShift)); + record[base + storageColumn] = linear ? storage.views.u8.byteOffset : remote === null ? 0 : storage.tags.byteOffset; + record[base + storageAdded] = storage.ticked ? storage.added.byteOffset : 0; + record[base + storageChanged] = storage.ticked ? storage.changed.byteOffset : 0; + record[base + storageChunkAdded] = storage.ticked ? storage.chunkAdded.byteOffset : 0; + record[base + storageChunkChanged] = storage.ticked ? storage.chunkChanged.byteOffset : 0; + record[base + storageChunks] = storage.chunkAdded.length; + } + Atomics.store(record, recordVersion, archetype.version); + // Entered once complete, so a reader that finds it finds a whole record. + if (fresh) directory.table[archetype.id] = record.byteOffset; +}; + +/** + * Writes the records of archetypes created or bumped since the last flush, the resource archetype's flagged, and the + * entity table's and the resource entity's if they moved. Runs at the end of each sync point and wherever registration + * changes layouts; allocates only for new records. + */ +export const flushDirectory = (directory: LayoutDirectory | null, bumped: Archetype[], archetypes: readonly Archetype[], entities: Entities, resourceEntity: number, counted: Archetype[]): void => { + if (directory === null) return; + const resourceWord = resourceEntity < 0 ? 0 : (resourceEntity >>> 0) + 1; + if (directory.header[resourceEntityWord] !== resourceWord) Atomics.store(directory.header, resourceEntityWord, resourceWord); + if (counted.length > 0) flushCounts(directory, counted); + if (bumped.length === 0 && entities.version === directory.entities) return; + if (entities.version !== directory.entities) { + writeEntities(directory, entities); + directory.entities = entities.version; + } + if (bumped.length === 0) return; + for (let k = 0; k < bumped.length; k++) { + const archetype = bumped[k]; + archetype.listed = false; + writeArchetype(directory, archetype); + } + bumped.length = 0; + Atomics.store(directory.header, tableLengthWord, archetypes.length); +}; + +/** + * A flush between sync points, as registration's: under the structure epoch, as a sync point's is, while a joined + * thread may read records. + */ +export const flushBetween = (directory: LayoutDirectory | null, bumped: Archetype[], archetypes: readonly Archetype[], entities: Entities, resourceEntity: number, counted: Archetype[]): void => { + if (directory === null) return; + const guarding = directory.guarded && (bumped.length > 0 || counted.length > 0 || entities.version !== directory.entities); + if (guarding) Atomics.add(directory.words, structureWord, 1); + flushDirectory(directory, bumped, archetypes, entities, resourceEntity, counted); + if (guarding) endStructure(directory); +}; + +/** Writes the counts of archetypes whose count changed, those with records; a new archetype's comes with its record. */ +const flushCounts = (directory: LayoutDirectory, counted: Archetype[]): void => { + for (let k = 0; k < counted.length; k++) { + const archetype = counted[k]; + archetype.counted = false; + const record = directory.records[archetype.id]; + if (record !== undefined) record[recordCount] = archetype.count; + } + counted.length = 0; +}; + +/** Lists an archetype whose count changed for the next flush, while a joined thread may read counts. */ +export const countChanged = (counted: Archetype[], archetype: Archetype): void => { + if (archetype.counted) return; + archetype.counted = true; + counted.push(archetype); +}; + +/** + * At the start of a sync point that applies anything, once a joined thread may read locations: the structure epoch + * turns odd, by an exchange, so the writes after it can't be seen before it. + */ +export const beginStructure = (directory: LayoutDirectory | null): void => { + if (directory !== null && directory.guarded) Atomics.add(directory.words, structureWord, 1); +}; + +/** + * Once a sync point's directory writes are done: the structure epoch turns even, by a store, since only the world + * writes it, waking readers only when one waits on it. + */ +export const endStructure = (directory: LayoutDirectory | null): void => { + if (directory === null) return; + const { words } = directory; + const epoch = words[structureWord]; + if ((epoch & 1) === 0) return; + Atomics.store(words, structureWord, epoch + 1); + if (Atomics.load(words, structureWaitersWord) !== 0) Atomics.notify(words, structureWord); +}; diff --git a/packages/ecs/src/implementation/world/lifetime.cases.ts b/packages/ecs/src/implementation/world/lifetime.cases.ts new file mode 100644 index 000000000..86c898878 --- /dev/null +++ b/packages/ecs/src/implementation/world/lifetime.cases.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./lifetime.fixture.js"; + +/** What kernels hold across the pool: the cases, over the fixture built for node or from source. */ +export const lifetimeCases = (load: () => Promise) => describe("what a kernel holds", () => { + it("lives as long as the kernel: new values of one kernel leave every table as it was, on every worker too", async () => { + const fixture = await load(); + const { before, after, collected, total, ran } = await fixture.freshKernels(200); + expect(total).toBe(fixture.rows - 1); + // Its report counts the kernel's run and the workers it ran on. + expect({ systems: ran.systems, workers: ran.workers, joined: ran.joined, gpu: ran.gpu }).toEqual({ systems: 1, workers: 2, joined: 0, gpu: false }); + expect(before.workers).toHaveLength(2); + // Workers keep a kernel by its code, so new values of it add nothing there, collected or not. + expect(after.workers).toEqual(before.workers); + // The world's thread releases what a value held once it's collected, where a collector is exposed. + if (collected) expect(after).toEqual(before); + }, 120_000); +}); diff --git a/packages/ecs/src/implementation/world/lifetime.fixture.ts b/packages/ecs/src/implementation/world/lifetime.fixture.ts new file mode 100644 index 000000000..d7b1bcd41 --- /dev/null +++ b/packages/ecs/src/implementation/world/lifetime.fixture.ts @@ -0,0 +1,77 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld, type Tables } from "./create.js"; +import { collector, freshKernel, tablesAfterCollecting } from "./lifetime.test-helper.js"; +import { readResource } from "./reads.test-helper.js"; +import { fillReports, runOf } from "./reports.test-helper.js"; +import { Report } from "../../interface/report/report.js"; + +/** Scenarios of what kernels hold across the pool, run built in node and from source in chromium. */ + +const value = Symbol("lifetimeFixture/value"); +const target = Symbol("lifetimeFixture/target"); +const out = Symbol("lifetimeFixture/out"); +const total = Symbol("lifetimeFixture/total"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [target]: { type: Entity }; + [out]: { type: number }; + } + export interface Resources { + [total]: { type: number; reduce: "+" }; + } +} + +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(target, { type: Entity.schema }); +Component.register(out, { type: { type: "number", precision: 1 } }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); + +export const rows = 4_096; + +const seed = System.create({ name: "lifetimeFixture/seed" }, ({ commands }) => { + let previous = commands.spawn([value])({ [value]: 0 }); + for (let i = 1; i < rows; i++) previous = commands.spawn([value, target, out])({ [value]: i, [target]: previous, [out]: 0 }); +}); + +/** A kernel that holds what kernels can: a lookup's directory, reduction partials and its load on each worker. */ +const measure = System.create({ + name: "lifetimeFixture/measure", + kernel: "cpu", + components: { read: [value, target], write: [out] }, + lookup: { targets: { read: [value] } }, + reduce: { [total]: "+" }, +}, (row, { lookup: { targets }, reduce }) => { + const other = targets(row[target]); + row[out] = other === null ? -1 : row[value] - other[value]; + reduce[total](1); +}); + +/** + * Runs `count` new values of a kernel, as a module evaluated again makes, each once, on two workers: the world's tables + * before and after, once collected where a collector is exposed; whether one was; and the last run's total. + */ +export const freshKernels = async (count: number): Promise<{ readonly before: Tables; readonly after: Tables; readonly collected: boolean; readonly total: number; readonly ran: Report[typeof Report.run] }> => { + const world = createWorld({ [workers]: 2 }, { minimumChunk: 64 }); + try { + await world.run(seed); + // Past the runs that spawn reports, which grow the entity table. + await fillReports(world); + await world.run(measure); + await world.run(freshKernel(measure)); + await world.run(measure); + const before = await tablesAfterCollecting(world); + for (let k = 0; k < count; k++) await world.run(freshKernel(measure)); + const last = await world.run(measure); + const after = await tablesAfterCollecting(world); + return { before, after, collected: collector() !== null, total: await readResource(world, total), ran: await runOf(world, last) }; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/lifetime.test-helper.ts b/packages/ecs/src/implementation/world/lifetime.test-helper.ts new file mode 100644 index 000000000..d6100a42a --- /dev/null +++ b/packages/ecs/src/implementation/world/lifetime.test-helper.ts @@ -0,0 +1,78 @@ +import type { System } from "../../interface/system/system.js"; +import type { Leaf } from "../../interface/system/leaf.js"; +import { definitionOf } from "../../interface/registry/index.js"; +import { claimSystem } from "../../interface/registry/program.js"; +import { attachKernel, kernelOf } from "./attach-kernel.js"; +import type { CreatedWorld, Tables } from "./create.js"; + +/** + * What finishes V8's optimizing compiles in flight, where node runs with `--allow-natives-syntax`; null elsewhere. A + * compile on a background thread holds the objects it read, systems among them, as roots until it's installed, which + * a busy machine can put off past any number of collections. + */ +const compiles = (): (() => void) | null => { + try { + // A string, since `%` calls parse only with the flag, and not at all in TypeScript. + const finish: unknown = new Function("%FinalizeOptimization();"); + return typeof finish === "function" ? () => { Reflect.apply(finish, undefined, []); } : null; + } + catch { + return null; + } +}; + +/** + * The garbage collector, once optimizing compiles in flight are finished, where node runs with `--expose-gc` and + * `--allow-natives-syntax`; null elsewhere, as in a browser. + */ +export const collector = (): (() => void) | null => { + const gc: unknown = Reflect.get(globalThis, "gc"); + const finish = compiles(); + if (typeof gc !== "function" || finish === null) return null; + return () => { + finish(); + Reflect.apply(gc, undefined, []); + }; +}; + +const task = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); + +/** + * Collects garbage, then lets finalization callbacks run, a few times over, since a callback may release what the next + * collection frees. Does nothing without a collector. + */ +export const collect = async (): Promise => { + const gc = collector(); + if (gc === null) return; + for (let round = 0; round < 4; round++) { + await task(); + gc(); + } + await task(); +}; + +/** The JS heap's used bytes after collecting, where node exposes the collector. */ +export const heapAfterCollecting = async (): Promise => { + await collect(); + return process.memoryUsage().heapUsed; +}; + +/** A world's tables once garbage is collected. */ +export const tablesAfterCollecting = async (world: CreatedWorld): Promise => { + await collect(); + return world.tables(); +}; + +/** + * A new value of a kernel system, with the same declaration, body and compiled kernel, as a module evaluated again + * makes: the build compiles kernels only at module scope, so no other code makes one. + */ +export const freshKernel = (system: System): System => { + const leaf = definitionOf(system); + const description = kernelOf(system); + if (leaf === undefined || leaf.kind === "composite" || description === undefined) throw new Error("Not a kernel system."); + // What `System.create` makes of the same declaration and body, without the id a top-level binding takes. + const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, body: leaf.body }; + claimSystem(copy, undefined); + return attachKernel(copy, description); +}; diff --git a/packages/ecs/src/implementation/world/lifetime.test.ts b/packages/ecs/src/implementation/world/lifetime.test.ts new file mode 100644 index 000000000..b42be3038 --- /dev/null +++ b/packages/ecs/src/implementation/world/lifetime.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld } from "./create.js"; +import { collector, heapAfterCollecting, tablesAfterCollecting } from "./lifetime.test-helper.js"; +import { fillReports } from "./reports.test-helper.js"; + +const count = Symbol("lifetimeTest/count"); +const parent = Symbol("lifetimeTest/parent"); +const score = Symbol("lifetimeTest/score"); + +declare module "@adobe/ecs" { + interface Components { + [count]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + } + interface Resources { + [score]: { type: number }; + } +} + +Component.register(count, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Resource.register(score, { type: { type: "number", default: 7 } }); + +const seed = System.create({ name: "lifetimeTest/seed" }, ({ commands }) => { + const root = commands.spawn([count])({ [count]: 1 }); + for (let i = 0; i < 4; i++) commands.spawn([count, parent])({ [count]: i, [parent]: root }); +}); + +/** A module-level system, run between and after the fresh ones, as an app's own systems are. */ +const steady = System.create({ name: "lifetimeTest/steady", components: { read: [count], changed: [count] } }, row => { void row[count]; }); + +let copied = 0; + +/** Fresh systems of each shape that holds something: a body alone, a resource read, a change filter, and `up`. */ +const freshSystems = (k: number): readonly System[] => [ + System.create(() => {}), + System.create({ resources: { read: [score] } }, ({ resources }) => { copied = resources[score] + k; }), + System.create({ components: { write: [count], changed: [count] } }, row => { row[count] += 1; }), + System.create({ components: { read: [count] }, up: { through: parent, read: [count] } }, (row, { up }) => { copied = row[count] + (up?.[count] ?? 0); }), +]; + +const fresh = 10_000; + +/** + * Runs `count` fresh systems, each once, then a module-level one. Its own function, since an async function's + * suspended frame keeps its last locals. + */ +const runFresh = async (world: World, count: number): Promise => { + for (let k = 0; k < count; k += 4) for (const system of freshSystems(k)) await world.run(system); + await world.run(steady); +}; + +describe("what a system holds", () => { + it("lives as long as the system: fresh systems run once leave every table as it was", async () => { + expect(collector()).not.toBeNull(); + const world = createWorld({ [workers]: 0 }); + await world.run(seed); + // Past the runs that spawn reports, which grow the entity table. + await fillReports(world); + await runFresh(world, 4); + const before = await tablesAfterCollecting(world); + await runFresh(world, fresh); + const after = await tablesAfterCollecting(world); + expect(copied).toBeGreaterThan(0); + expect(after).toEqual(before); + await world.dispose(); + }, 120_000); + + it("leaves the JS heap where it was once fresh systems run once are collected", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed); + // The first runs compile the paths they take and grow tables to hold what's uncollected between collections. + await runFresh(world, 2 * fresh); + const before = await heapAfterCollecting(); + await runFresh(world, 2 * fresh); + const after = await heapAfterCollecting(); + // About 12 bytes a system, which a kept runner, label or unregister token exceeds, and the heap's noise doesn't. + expect(after - before).toBeLessThan(250_000); + await world.dispose(); + }, 120_000); +}); diff --git a/packages/ecs/src/implementation/world/lists.ts b/packages/ecs/src/implementation/world/lists.ts new file mode 100644 index 000000000..5c68e98b7 --- /dev/null +++ b/packages/ecs/src/implementation/world/lists.ts @@ -0,0 +1,152 @@ +import type { Entity } from '../../interface/entity/entity.js'; +import { archetypeIndexOf, labelOf } from '../../interface/registry/index.js'; +import { batchOf } from './batches.js'; +import { reserve } from './entities.js'; +import { claim, generated, openRow, writerOf, type Column, type Positional, type SpawnBatch, type Writer } from './staging.js'; +import { commandIdOf, type State } from './state.js'; + +/** Values by key, as spawns and adds read them. */ +export type Values = { readonly [key: symbol]: unknown }; + +/** Spawns one entity of a list's components, writing its values into the list's batch. */ +export type Spawner = (values: Values) => Entity; + +/** Where a shape's spawns go: its batch, and the components holding values, each with its key, column and writer. */ +export type Written = { + readonly batch: SpawnBatch; + readonly keys: readonly symbol[]; + readonly columns: readonly Column[]; + readonly writers: readonly Writer[]; +}; + +/** + * A set of components commands named, by their keys: keys and ids in the order of the list that first named them, what + * each starts at when a spawn or add leaves it out, and, made on the first spawn, where spawns go and its spawner. + */ +export type ListShape = { + /** Its index among the world's lists, by which commands name it. */ + readonly index: number; + /** By source archetype id: the archetype an add or a remove of these components leaves, once a sync point resolved it. */ + readonly adding: number[]; + readonly removing: number[]; + readonly keys: readonly symbol[]; + readonly ids: readonly number[]; + readonly starts: readonly unknown[]; + written: Written | null; + spawner: Spawner | null; +}; + +/** A plain list named recently, found by its keys, copied, since it may change. */ +export type Recent = { readonly keys: readonly unknown[]; readonly shape: ListShape }; + +const recentCount = 8; + +/** Resolves a list, checking each key is a component commands may name, listed once. Throws otherwise. */ +const resolve = (state: State, list: readonly unknown[]): ListShape => { + const ids = list.map(key => commandIdOf(state, key)); + for (let k = 1; k < ids.length; k++) { + if (ids.indexOf(ids[k]) < k) throw new Error(`A list of components lists "${labelOf(list[k])}" twice.`); + } + const keys = list.filter(key => typeof key === "symbol"); + const name = [...ids].sort((a, b) => a - b).join(","); + // Symbols a module evaluated again makes are the same components, but values are keyed by the caller's own. + const shapes = state.lists.get(name) ?? []; + const found = shapes.find(shape => keys.every(key => shape.keys.includes(key))); + if (found !== undefined) return found; + const shape: ListShape = { index: state.shapes.length, adding: [], removing: [], keys, ids, starts: ids.map(id => state.starts[id]), written: null, spawner: null }; + state.lists.set(name, [...shapes, shape]); + state.shapes.push(shape); + return shape; +}; + +/** + * The shape of a list of components: an archetype `Archetype.create` indexed, which never changes, by its index; any + * other list by its keys among the recent plain lists, else resolved and made recent. Lists of the same components in + * any order share a shape. + */ +export const listShapeOf = (state: State, list: readonly unknown[]): ListShape => { + const index = archetypeIndexOf(list); + // Kept small, so it inlines into the systems spawning and adding: the rest is outlined. + return (index >= 0 ? state.byIndex[index] : undefined) ?? shapeOfList(state, list, index); +}; + +/** A list's shape, other than an archetype's already resolved. */ +const shapeOfList = (state: State, list: readonly unknown[], index: number): ListShape => { + if (index >= 0) { + const shape = resolve(state, list); + state.byIndex[index] = shape; + return shape; + } + const { recent } = state; + const length = list.length; + for (let r = 0; r < recent.length; r++) { + const { keys } = recent[r]; + if (keys.length !== length) continue; + let same = true; + for (let k = 0; k < length && same; k++) same = keys[k] === list[k]; + if (same) return recent[r].shape; + } + const shape = resolve(state, list); + const entry: Recent = { keys: [...list], shape }; + if (recent.length < recentCount) recent.push(entry); + else recent[state.nextRecent = (state.nextRecent + 1) % recentCount] = entry; + return shape; +}; + +/** Where a shape's spawns go, resolved once. A constant's column holds nothing, so nothing is written or read for it. */ +export const writtenOf = (state: State, shape: ListShape): Written => { + if (shape.written !== null) return shape.written; + const batch = batchOf(state, shape.ids); + const held = shape.ids.flatMap((id, k) => state.layouts[id].kind === "constant" ? [] : [k]); + const columns = held.map(k => batch.columns[batch.ids.indexOf(shape.ids[k])]); + shape.written = { batch, keys: held.map(k => shape.keys[k]), columns, writers: held.map((k, c) => writerOf(columns[c], shape.starts[k])) }; + return shape.written; +}; + +/** Spawns by reading each value by key in a loop: where code can't be generated. */ +const loopSpawner = (state: State, { batch, keys, writers }: Written): Spawner => { + const { entities } = state; + const count = keys.length; + return values => { + const row = openRow(batch); + for (let k = 0; k < count; k++) writers[k](row, values[keys[k]]); + const entity = reserve(entities); + claim(batch, entity); + return entity; + }; +}; + +export type { Positional }; + +/** + * Spawns of a shape taking values positionally in `order`, a site's key order: generated, else a loop over the + * writers. A key the shape doesn't name is ignored. + */ +export const positionalOf = (state: State, shape: ListShape, order: readonly unknown[]): Positional => { + const { batch, keys, columns, writers } = writtenOf(state, shape); + const at = keys.map(key => order.indexOf(key)); + const made = generated(state.entities, reserve, batch, order.map((_, k) => `v${k}`), keys, columns, writers, k => at[k] < 0 ? "undefined" : `v${at[k]}`); + if (made !== null) return made; + const { entities } = state; + return (...values) => { + const row = openRow(batch); + for (let k = 0; k < writers.length; k++) writers[k](row, at[k] < 0 ? undefined : values[at[k]]); + const entity = reserve(entities); + claim(batch, entity); + return entity; + }; +}; + +/** + * The spawner of a shape, made once: it writes each value it reads by key into the shape's batch, a tag or `undefined` + * as the component's start, then reserves the entity, so a value that throws leaves nothing behind. + */ +export const spawnerOf = (state: State, shape: ListShape): Spawner => shape.spawner ?? makeSpawner(state, shape); + +/** Makes a shape's spawner, the first time it spawns. */ +const makeSpawner = (state: State, shape: ListShape): Spawner => { + const written = writtenOf(state, shape); + const { batch, keys, columns, writers } = written; + shape.spawner = generated(state.entities, reserve, batch, ["values"], keys, columns, writers, k => `values[k${k}]`) ?? loopSpawner(state, written); + return shape.spawner; +}; diff --git a/packages/ecs/src/implementation/world/lookup-keys.ts b/packages/ecs/src/implementation/world/lookup-keys.ts new file mode 100644 index 000000000..949ea320c --- /dev/null +++ b/packages/ecs/src/implementation/world/lookup-keys.ts @@ -0,0 +1,48 @@ +import type { SystemLookup } from '../../interface/system/access.js'; +import { indexDefinitionOf, indexOfLookup, watchedBy } from '../../interface/registry/index.js'; +import type { Component } from '../../interface/component/component.js'; + +/** Every component a lookup names: what it reads, matches on and watches, and for an index lookup the index's own. */ +export const lookupComponentsOf = (lookup: SystemLookup): readonly Component.Key[] => { + const index = indexOfLookup(lookup); + if (index === undefined) return [...lookup.read ?? [], ..."has" in lookup ? lookup.has ?? [] : [], ..."not" in lookup ? lookup.not ?? [] : []]; + const { key, components } = index.declaration; + return [...lookup.read ?? [], ...watchedBy(lookup), ...key, ...components?.has ?? [], ...components?.not ?? []]; +}; + +const none: readonly Component.Key[] = []; + +/** + * The components whose changes the indexes of a declaration's lookups follow, which keep ticks and kernels' stamps + * while it lives: each index's key parts, and what its lookups watch. + */ +export const indexWatchesOf = (lookup: { readonly [name: string]: SystemLookup } | undefined): readonly Component.Key[] => { + if (lookup === undefined) return none; + const keys: Component.Key[] = []; + for (const name in lookup) { + const declared = lookup[name]; + const index = indexOfLookup(declared); + if (index === undefined) continue; + keys.push(...index.declaration.key, ...watchedBy(declared)); + } + return keys; +}; + +/** The components a declaration's lookups by entity watch: their `changed`, which keep ticks and kernels' stamps while it lives. */ +export const entityWatchesOf = (lookup: { readonly [name: string]: SystemLookup } | undefined): readonly Component.Key[] => { + if (lookup === undefined) return none; + const keys: Component.Key[] = []; + for (const name in lookup) { + const declared = lookup[name]; + if (indexOfLookup(declared) === undefined) keys.push(...watchedBy(declared)); + } + return keys; +}; + +/** Every component the index a declaration orders its rows by names: its key parts, `has` and `not`. */ +export const orderComponentsOf = (declaration: { readonly order?: unknown }): readonly Component.Key[] => { + const index = indexDefinitionOf(declaration.order); + if (index === undefined) return none; + const { key, components } = index.declaration; + return [...key, ...components?.has ?? [], ...components?.not ?? []]; +}; diff --git a/packages/ecs/src/implementation/world/loop.test.ts b/packages/ecs/src/implementation/world/loop.test.ts new file mode 100644 index 000000000..7f8efa289 --- /dev/null +++ b/packages/ecs/src/implementation/world/loop.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { Component, System } from "../../index.js"; +import { requireDefinition, type Composite } from "../../interface/registry/index.js"; +import type { Cadence } from "../../interface/resources/cadence.js"; +import { backToBack, createLoop, type Clock, type LoopSettings } from "./loop.js"; +import { createWorld } from "./create.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { Observe } from "../../observe/index.js"; + +const count = Symbol("loopTest/count"); +declare module "@adobe/ecs" { + interface Components { + [count]: { type: number }; + } +} +Component.register(count, { type: { type: "number" } }); + +const increment = requireDefinition(System.create({ name: "loopTest/increment", components: { write: [count] } }, row => { row[count] += 1; })); +const reader = requireDefinition(System.create({ name: "loopTest/reader", components: { read: [count] } }, row => { void row[count]; })); +const ordered = requireDefinition(System.create({ name: "loopTest/ordered", components: { read: [count] }, after: [System.create({ name: "loopTest/phase" }, () => {})] }, row => { void row[count]; })); +const takesInput = requireDefinition(System.create({ name: "loopTest/input", input: { type: "object", properties: { by: { type: "number" } } } }, () => {})); + +/** A host whose frames and timers run only when the test says, at the times it gives. */ +const createHost = (settings: { cadence: Cadence; always: boolean; wake: number }) => { + let time = 0; + let frames: ((time: number) => void)[] = []; + const timers: { at: number; callback: () => void; live: boolean }[] = []; + const ticks: Clock[] = []; + const host = { + settings: (): LoopSettings => settings, + tick: async (_: Composite, clock: Clock): Promise => { ticks.push({ ...clock }); }, + now: () => time, + frame: (callback: (time: number) => void) => { + frames.push(callback); + return () => { frames = frames.filter(other => other !== callback); }; + }, + timer: (callback: () => void, milliseconds: number) => { + const timer = { at: time + milliseconds, callback, live: true }; + timers.push(timer); + return () => { timer.live = false; }; + }, + }; + /** Advances time, firing due timers, then gives a frame at the new time to whatever asked for one. */ + const advance = async (milliseconds: number, frame = true): Promise => { + time += milliseconds; + for (const timer of timers) { + if (timer.live && timer.at <= time) { + timer.live = false; + timer.callback(); + } + } + if (frame) { + const due = frames; + frames = []; + for (const callback of due) callback(time); + } + // Ticks resolve through promises. + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + return { host, ticks, advance, pending: () => frames.length + timers.filter(timer => timer.live).length }; +}; + +describe("the loop", () => { + it("runs no tick while quiet, and one on the beat after a run arrives", async () => { + const { host, ticks, advance, pending } = createHost({ cadence: { kind: "frame" }, always: false, wake: Infinity }); + const loop = createLoop(host); + loop.add(increment); + await advance(16); + expect(ticks).toEqual([]); + expect(pending()).toBe(0); + loop.arrived(); + loop.arrived(); + await advance(16); + expect(ticks).toEqual([{ now: 32, delta: 0, tick: 0 }]); + await advance(16); + expect(ticks).toHaveLength(1); + loop.arrived(); + await advance(16); + expect(ticks[1]).toEqual({ now: 64, delta: 32, tick: 1 }); + }); + + it("runs nothing while no system is added, even when runs arrive", async () => { + const { host, ticks, advance, pending } = createHost({ cadence: { kind: "frame" }, always: true, wake: Infinity }); + const loop = createLoop(host); + loop.arrived(); + await advance(16); + expect([ticks.length, pending()]).toEqual([0, 0]); + }); + + it("ticks every frame while always is set", async () => { + const settings = { cadence: { kind: "frame" } as Cadence, always: true, wake: Infinity }; + const { host, ticks, advance } = createHost(settings); + createLoop(host).add(increment); + for (let i = 0; i < 3; i++) await advance(10); + expect(ticks.map(clock => clock.delta)).toEqual([0, 10, 10]); + settings.always = false; + await advance(10); + await advance(10); + expect(ticks).toHaveLength(3); + }); + + it("ticks once when a wake time comes, and not again for the same wake", async () => { + const settings = { cadence: { kind: "frame" } as Cadence, always: false, wake: 50 }; + const { host, ticks, advance } = createHost(settings); + createLoop(host).add(increment); + await advance(40); + expect(ticks).toEqual([]); + await advance(20); + expect(ticks.map(clock => clock.now)).toEqual([60]); + await advance(100); + expect(ticks).toHaveLength(1); + }); + + it("under a fixed cadence ticks by step, at most maxSteps per beat, and builds no debt while quiet", async () => { + const settings = { cadence: { kind: "fixed", step: 10, maxSteps: 2 } as Cadence, always: true, wake: Infinity }; + const { host, ticks, advance } = createHost(settings); + createLoop(host).add(increment); + await advance(0, false); + expect(ticks).toEqual([{ now: 0, delta: 10, tick: 0 }]); + await advance(10, false); + expect(ticks.map(clock => clock.now)).toEqual([0, 10]); + // Behind by 5 steps: 2 run and the rest of the debt is dropped. + await advance(50, false); + expect(ticks.map(clock => clock.now)).toEqual([0, 10, 20, 30]); + await advance(10, false); + expect(ticks.map(clock => clock.now)).toEqual([0, 10, 20, 30, 70]); + expect(ticks.every(clock => clock.delta === 10)).toBe(true); + settings.always = false; + await advance(1000, false); + expect(ticks).toHaveLength(5); + }); + + it("runs a fixed cadence's default of 4 steps per beat when behind", async () => { + const { host, ticks, advance } = createHost({ cadence: { kind: "fixed", step: 1 }, always: true, wake: Infinity }); + createLoop(host).add(increment); + await advance(0, false); + await advance(100, false); + expect(ticks).toHaveLength(5); + }); + + it("rejects a system that conflicts with one added and has no order to it, or takes input", () => { + const { host } = createHost({ cadence: { kind: "frame" }, always: false, wake: Infinity }); + const loop = createLoop(host); + loop.add(increment); + expect(() => loop.add(reader)).toThrow('Systems "loopTest/increment" and "loopTest/reader" both access "loopTest/count" and one writes it'); + expect(() => loop.add(takesInput)).toThrow("takes input, which ticks don't pass"); + loop.add(increment); + expect(() => loop.add(ordered)).toThrow("both access"); + }); + + it("schedules nothing more once stopped", async () => { + const { host, ticks, advance, pending } = createHost({ cadence: { kind: "frame" }, always: true, wake: Infinity }); + const loop = createLoop(host); + loop.add(increment); + loop.stop(); + await advance(16); + expect([ticks.length, pending()]).toEqual([0, 0]); + expect(() => loop.add(reader)).toThrow("disposed"); + }); +}); + +describe("a world's loop platform", () => { + it("can be given, so a back-to-back driver ticks as fast as tasks run", async () => { + const driver = backToBack(); + const world = createWorld({ [always]: true }, { loop: driver }); + world.add(System.create({ name: "loopTest/idle", components: { has: [count] } }, () => {})); + const start = performance.now(); + // At 60 frames a second, a timer's frames would take 1.6 s. + await new Promise(resolve => { + let ticks = 0; + const unobserve = Observe.resource(world, clock)(() => { + if (++ticks < 100) return; + unobserve(); + resolve(); + }); + }); + expect(performance.now() - start).toBeLessThan(800); + await world.dispose(); + driver.close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/loop.ts b/packages/ecs/src/implementation/world/loop.ts new file mode 100644 index 000000000..ccea7f0a6 --- /dev/null +++ b/packages/ecs/src/implementation/world/loop.ts @@ -0,0 +1,295 @@ +import type { Cadence } from '../../interface/resources/cadence.js'; +import type { Composite, Definition } from '../../interface/registry/index.js'; +import { labelOfItem } from '../../interface/registry/index.js'; +import { requireDefinition } from '../../interface/registry/program.js'; +import { isLeaf, isObserver, scheduleOf } from '../../interface/registry/schedule.js'; +import type { Leaf } from '../../interface/system/leaf.js'; + +/** What the loop reads of the world's resources before each beat. */ +export type LoopSettings = { readonly cadence: Cadence; readonly always: boolean; readonly wake: number }; + +/** One tick's clock: `now` and `delta` in milliseconds, and how many ticks ran before. */ +export type Clock = { now: number; delta: number; tick: number }; + +/** What the loop needs of the world and the platform; tests give their own timers. */ +export type LoopHost = { + readonly settings: () => LoopSettings; + /** Runs one tick of `composite` with this clock, through the world's queue. */ + readonly tick: (composite: Composite, clock: Clock) => Promise; + readonly now: () => number; + readonly frame: (callback: (time: number) => void) => () => void; + readonly timer: (callback: () => void, milliseconds: number) => () => void; +}; + +/** What the loop takes from the platform; a world can be given its own, for tests and benchmarks. */ +export type LoopPlatform = Pick; + +export type Loop = { + /** + * Adds a system to every tick from the next one on, or an observer system to the observer systems. Throws for a + * conflict with one already added, or input. + */ + readonly add: (system: Definition) => void; + /** + * Removes a value `add` was given from every tick from the next one on, returning the composite of the added + * systems before and after, null while none is added: the same one for an observer system added alone, which leaves + * it as it is. Null without a change, for a value not added. Throws, removing nothing, when the systems left + * conflict with no order between them. + */ + readonly remove: (system: Definition) => { readonly previous: Composite | null; readonly next: Composite | null } | null; + /** The composite ticks run now: the added systems', or null while none is added that runs in a tier. */ + readonly current: () => Composite | null; + /** + * The observer systems to run after every run's and tick's sync point: those added alone and those within added + * composites, each once. A new array once they change, so a pass in progress keeps the one it started with. + */ + readonly observer: () => readonly Leaf[]; + /** A run arrived: the next beat runs a tick. */ + readonly arrived: () => void; + /** Stops ticking; a tick in progress finishes. */ + readonly stop: () => void; +}; + +/** A beat of a fixed cadence that's behind runs at most this many ticks, unless the cadence says otherwise. */ +export const defaultMaxSteps = 4; + +/** Milliseconds between beats of a frame cadence where the platform has no animation frames. */ +const frameMilliseconds = 1000 / 60; + +/** Animation frames where the platform has them, and a timer at 60 per second otherwise. */ +export const platformFrame = (callback: (time: number) => void): (() => void) => { + if (typeof globalThis.requestAnimationFrame === "function") { + const handle = globalThis.requestAnimationFrame(callback); + return () => globalThis.cancelAnimationFrame(handle); + } + const handle = setTimeout(() => callback(performance.now()), frameMilliseconds); + return () => clearTimeout(handle); +}; + +export const platformTimer = (callback: () => void, milliseconds: number): (() => void) => { + const handle = setTimeout(callback, milliseconds); + return () => clearTimeout(handle); +}; + +/** + * For tests and benchmarks: frames that call back as soon as the event loop takes its next task, through a + * `MessageChannel` post. A task, so worker messages and `Atomics.waitAsync` settle between ticks, which microtasks + * would starve; unclamped, as `setTimeout(0)` isn't. `close` releases the channel, which keeps node running while open. + */ +export const backToBack = (): Partial & { readonly close: () => void } => { + const channel = new MessageChannel(); + let pending: ((time: number) => void) | null = null; + channel.port1.onmessage = () => { + const callback = pending; + pending = null; + callback?.(performance.now()); + }; + return { + frame: callback => { + pending = callback; + channel.port2.postMessage(null); + return () => { + if (pending === callback) pending = null; + }; + }, + close: () => { + channel.port1.close(); + channel.port2.close(); + }, + }; +}; + +/** Throws unless every leaf under a system takes no input, since ticks pass none. */ +const checkNoInput = (system: Definition, seen: Set = new Set()): void => { + if (seen.has(system)) return; + seen.add(system); + if (isLeaf(system)) { + if (system.declaration.input !== undefined) throw new Error(`System "${labelOfItem(system)}" takes input, which ticks don't pass; run it instead.`); + return; + } + for (const member of system.declaration.systems) checkNoInput(requireDefinition(member), seen); +}; + +const noLeaves: readonly Leaf[] = []; + +/** + * The world's loop: on beats of its cadence, while some system is added that runs in a tier, it runs a tick where + * `always` is set, `wake` is due or a run arrived since the last tick. A quiet loop schedules nothing until a run + * arrives or a wake time comes. Observer systems don't make it tick. + */ +export const createLoop = (host: LoopHost): Loop => { + /** The values added but observer systems added alone, which are in `alone`. */ + const systems: Definition[] = []; + let composite: Composite | null = null; + /** `composite` while it has a tier, so ticks run. */ + let ticking: Composite | null = null; + const alone: Leaf[] = []; + let observer: readonly Leaf[] = noLeaves; + const refreshObserver = (): void => { + const within = composite === null ? noLeaves : scheduleOf(composite).observer; + observer = within.length === 0 ? [...alone] : [...new Set([...alone, ...within])]; + }; + /** Sets the composite of the added systems, and whether it ticks. */ + const compose = (next: Composite | null): void => { + composite = next; + ticking = next !== null && scheduleOf(next).tiers.length > 0 ? next : null; + refreshObserver(); + }; + let runArrived = false; + let stopped = false; + /** A beat or wake timer is pending, cancelled by this. */ + let cancel: (() => void) | null = null; + /** Whether what's pending is a timer until a wake time, rather than a beat. */ + let waiting = false; + let running = false; + let ticks = 0; + /** The previous tick's time, for a frame cadence's delta. */ + let previous: number | null = null; + /** Under a fixed cadence, the time ticks have reached; null while quiet, so a quiet loop builds no debt. */ + let reached: number | null = null; + /** The wake time a tick already ran for, so a wake no system asks for again doesn't tick every beat. */ + let served = Infinity; + const clock: Clock = { now: 0, delta: 0, tick: 0 }; + + const isDue = (settings: LoopSettings, time: number): boolean => + settings.always || runArrived || (settings.wake <= time && settings.wake !== served); + + const runTicks = async (settings: LoopSettings, time: number, first: Composite): Promise => { + const { cadence } = settings; + const wake = settings.wake; + if (cadence.kind === "frame") { + clock.now = time; + clock.delta = previous === null ? 0 : time - previous; + clock.tick = ticks++; + previous = time; + if (wake <= time) served = wake; + await host.tick(first, clock); + return; + } + const { step } = cadence; + const maxSteps = cadence.maxSteps ?? defaultMaxSteps; + reached ??= time - step; + const behind = Math.floor((time - reached) / step); + const count = Math.min(behind, maxSteps); + const start = reached; + // Steps past `maxSteps` are dropped, keeping the cadence's phase. + reached += Math.max(behind, 0) * step; + if (wake <= time) served = wake; + for (let i = 1; i <= count; i++) { + // Each tick runs what's added as it starts: systems removed since the last one aren't in it. + const current = ticking; + if (stopped || current === null) return; + clock.now = start + i * step; + clock.delta = step; + clock.tick = ticks++; + previous = clock.now; + await host.tick(current, clock); + } + }; + + const beat = async (time: number): Promise => { + cancel = null; + waiting = false; + if (stopped || ticking === null) return; + const settings = host.settings(); + if (!isDue(settings, time)) { + schedule(); + return; + } + runArrived = false; + running = true; + try { + await runTicks(settings, time, ticking); + } + finally { + running = false; + } + schedule(); + }; + + const onBeat = (time: number): void => { void beat(time); }; + + /** Schedules the next beat, or a timer until the next wake time, or nothing while the loop is quiet. */ + const schedule = (): void => { + if (stopped || ticking === null || running || cancel !== null) return; + const settings = host.settings(); + const { cadence } = settings; + const now = host.now(); + if (settings.always || runArrived) { + if (cadence.kind === "frame") cancel = host.frame(onBeat); + else { + const delay = reached === null ? 0 : Math.max(0, reached + cadence.step - now); + cancel = host.timer(() => onBeat(host.now()), delay); + } + return; + } + // Quiet: a fixed cadence builds no debt until it ticks again. + reached = null; + if (settings.wake === Infinity || settings.wake === served) return; + waiting = true; + cancel = host.timer(() => { + cancel = null; + waiting = false; + if (cadence.kind === "frame") cancel = host.frame(onBeat); + else onBeat(host.now()); + }, Math.max(0, settings.wake - now)); + }; + + return { + add: system => { + if (stopped) throw new Error("The world is disposed."); + checkNoInput(system); + if (isObserver(system)) { + if (alone.includes(system)) return; + alone.push(system); + refreshObserver(); + return; + } + if (systems.includes(system)) return; + const next = [...systems, system]; + const candidate: Composite = { kind: "composite", name: "tick", declaration: { name: "tick", systems: next } }; + // Throws for members ordered in a cycle, or conflicting with no order between them. + scheduleOf(candidate); + systems.push(system); + compose(candidate); + schedule(); + }, + remove: system => { + if (isObserver(system)) { + const at = alone.indexOf(system); + if (at < 0) return null; + alone.splice(at, 1); + refreshObserver(); + return { previous: composite, next: composite }; + } + const at = systems.indexOf(system); + if (at < 0 || composite === null) return null; + const left = systems.filter((_, k) => k !== at); + const next: Composite | null = left.length === 0 ? null : { kind: "composite", name: "tick", declaration: { name: "tick", systems: left } }; + // Throws for members that conflict with no ordering path left between them. + if (next !== null) scheduleOf(next); + const previous = composite; + systems.splice(at, 1); + compose(next); + return { previous, next }; + }, + current: () => ticking, + observer: () => observer, + arrived: () => { + if (ticking === null) return; + runArrived = true; + if (waiting && cancel !== null) { + // A wake time may be far off; a run is due on the next beat. + cancel(); + cancel = null; + waiting = false; + } + schedule(); + }, + stop: () => { + stopped = true; + cancel?.(); + cancel = null; + }, + }; +}; diff --git a/packages/ecs/src/implementation/world/memory.test.ts b/packages/ecs/src/implementation/world/memory.test.ts new file mode 100644 index 000000000..53af1a935 --- /dev/null +++ b/packages/ecs/src/implementation/world/memory.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { coveringBuffer, createMemory, pageBytes } from "./memory.js"; + +/** + * A memory whose buffer stays short until it grows, by nothing too, as a thread's does when its memory object arrived + * in a message while another thread grew the memory. + */ +const missedGrowth = (short: number, long: number) => { + let grows = 0; + const shortBuffer = new SharedArrayBuffer(short); + const longBuffer = new SharedArrayBuffer(long); + return { + get buffer(): SharedArrayBuffer { + return grows === 0 ? shortBuffer : longBuffer; + }, + grow: (delta: number): number => { + grows++; + return long / pageBytes + delta; + }, + grows: () => grows, + }; +}; + +describe("coveringBuffer", () => { + it("refreshes a buffer shorter than the length the world stored, by growing the memory by nothing", () => { + const memory = missedGrowth(4, 8); + expect(coveringBuffer(memory, 8).byteLength).toBe(8); + expect(memory.grows()).toBe(1); + }); + it("leaves a buffer that covers the length as it is", () => { + const memory = missedGrowth(8, 16); + expect(coveringBuffer(memory, 8).byteLength).toBe(8); + expect(memory.grows()).toBe(0); + }); + it("never grows unshared memory, which would detach its views", () => { + const memory = createMemory(false); + const buffer = memory.memory.buffer; + expect(coveringBuffer(memory.memory, 2 * pageBytes)).toBe(buffer); + expect(buffer.byteLength).toBe(pageBytes); + }); +}); diff --git a/packages/ecs/src/implementation/world/memory.ts b/packages/ecs/src/implementation/world/memory.ts new file mode 100644 index 000000000..cb1e3d4b5 --- /dev/null +++ b/packages/ecs/src/implementation/world/memory.ts @@ -0,0 +1,89 @@ +/** Bytes per WebAssembly page. */ +export const pageBytes = 65536; +/** 4 GiB, the wasm32 limit. Shared memory reserves address space up front, not physical memory. */ +const maximumPages = 65536; +/** Smallest block; also the alignment of every block, so columns never share a cache line. */ +const minimumBlockBytes = 64; + +/** + * The layout directory's header (`layout-directory.ts`): the first block, at offset 0, which never moves. Its first word is + * the memory's length in pages, stored right after each grow, since 4 GiB in bytes doesn't fit a 32-bit word. + */ +export const headerBytes = minimumBlockBytes; +export const pagesWord = 0; + +/** + * This thread's `memory.buffer` of shared memory, at least `bytes` long once the memory is. Another thread's growth + * reaches this thread's buffer only when this thread handles the interrupt growth sends it, and never when this + * thread's memory object arrived in a message while the memory grew, so a buffer shorter than the length the world + * stored is refreshed by growing the memory by nothing, which brings it to the memory's length. + */ +export const coveringBuffer = (memory: { readonly buffer: T; readonly grow: (delta: number) => number }, bytes: number): T => { + const buffer = memory.buffer; + // Growing unshared memory, even by nothing, would detach every view of it. + if (buffer.byteLength >= bytes || !(buffer instanceof SharedArrayBuffer)) return buffer; + memory.grow(0); + return memory.buffer; +}; + +/** This thread's `memory.buffer` of shared memory, covering the length in pages its header holds. */ +export const currentBuffer = (memory: WebAssembly.Memory): ArrayBuffer => + coveringBuffer(memory, Atomics.load(new Uint32Array(memory.buffer, 0, 1), pagesWord) * pageBytes); + +/** + * A shared `WebAssembly.Memory` arena with power-of-two size classes. + * Grows only when called from a sync point; views over a shared buffer stay valid as it grows. + * Without shared memory (a page that isn't cross-origin isolated), each array gets its own `ArrayBuffer` + * instead, since growing unshared memory would detach every view; that world runs on the main thread only. + */ +export type Memory = { + readonly memory: WebAssembly.Memory; + readonly shared: boolean; + /** The layout directory's header, at offset 0; for unshared memory, an array of its own that no other thread reads. */ + readonly header: Uint32Array; + /** Returns a 64-byte aligned offset of a block with at least `bytes`. Contents are unspecified. */ + readonly allocate: (bytes: number) => number; + readonly free: (offset: number, bytes: number) => void; + /** Bytes in blocks allocated and not freed, for tests of what a world holds. */ + readonly held: () => number; +}; + +const sizeClass = (bytes: number): number => Math.max(0, Math.ceil(Math.log2(Math.max(bytes, minimumBlockBytes) / minimumBlockBytes))); + +/** Shared memory is available in node and in cross-origin isolated pages. */ +export const sharedMemoryAvailable = (): boolean => globalThis.crossOriginIsolated !== false; + +export const createMemory = (shared = sharedMemoryAvailable()): Memory => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: shared ? maximumPages : 1, shared }); + const freeBlocks: number[][] = []; + let top = shared ? headerBytes : 0; + let held = 0; + const header = shared ? new Uint32Array(memory.buffer, 0, headerBytes / 4) : new Uint32Array(headerBytes / 4); + header[pagesWord] = memory.buffer.byteLength / pageBytes; + + const allocate = (bytes: number): number => { + const size = sizeClass(bytes); + held += minimumBlockBytes * 2 ** size; + const reused = freeBlocks[size]?.pop(); + if (reused !== undefined) return reused; + const offset = top; + top += minimumBlockBytes * 2 ** size; + const shortfall = top - memory.buffer.byteLength; + // Grow at least by half again, so filling a world grows memory a logarithmic number of times. + if (shortfall > 0) { + const pages = memory.buffer.byteLength / pageBytes; + memory.grow(Math.min(maximumPages - pages, Math.max(Math.ceil(shortfall / pageBytes), Math.ceil(pages / 2)))); + // Other threads check their buffer covers it at each task's start. + Atomics.store(header, pagesWord, memory.buffer.byteLength / pageBytes); + } + return offset; + }; + + const free = (offset: number, bytes: number): void => { + const size = sizeClass(bytes); + held -= minimumBlockBytes * 2 ** size; + (freeBlocks[size] ??= []).push(offset); + }; + + return { memory, shared, header, allocate, free, held: () => held }; +}; diff --git a/packages/ecs/src/implementation/world/mirror.ts b/packages/ecs/src/implementation/world/mirror.ts new file mode 100644 index 000000000..83cd0ab51 --- /dev/null +++ b/packages/ecs/src/implementation/world/mirror.ts @@ -0,0 +1,603 @@ +import { indexOf } from './entities.js'; +import { + cursorField, entitiesWord, recordCount, recordEntities, recordStorages, recordWords, + remoteFlag, storageAdded, storageChanged, storageColumn, storageComponent, storageFlags, storageWords, structureWaitersWord, + structureWord, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, +} from './layout-directory.js'; +import { + appendEntry, archetypeShift, entryWords, kindMask, logCapacityWord, logHeaderBytes, logLimitWord, logOffsetWord, + logPublishedWord, logSyncWord, logVersionWord, logBoundWord, logBoundedWord, moveEntry, nullTag, ownTag, rangeEntry, setEntry, slotTags, startTag, swapEntry, syncEntry, +} from './move-log.js'; +import { coveringBuffer, currentBuffer, pageBytes, pagesWord } from './memory.js'; +import { releaseValue } from './transfers.js'; + +// A joined thread's object columns: by row, as the world's are, following the world's rows through the move log +// (`move-log.ts`), replayed lazily, or rebuilt from the layout directory when the log lapped it. Their ticks are the +// world's storages', in shared memory, which the world stamps at sync points and this thread's systems as they write. + +/** One of this thread's object components in one archetype: its values by row. */ +export type Column = { + readonly component: number; + readonly start: unknown; + values: unknown[]; +}; + +/** An archetype holding this thread's objects, by the world's number: its columns, and each row's entity. */ +export type Mirrored = { + readonly columns: readonly Column[]; + /** Columns by component number. */ + readonly byComponent: readonly (Column | undefined)[]; + entities: Float64Array; + count: number; +}; + +/** A value this thread gave one of its objects, staged until a sync point applies it or drops its command. */ +/** A staged value: of a component declared `transfer`, it's released if its command drops. */ +type Staged = { readonly slot: number; readonly entity: number; readonly component: number; readonly value: unknown; readonly sync: number; readonly transfer: boolean; live: boolean }; + +/** Whether replaying is done, waits for a component's key to be named, or found the log lapped or replaced. */ +export type Replayed = "current" | "held" | "lapped"; + +/** A joined thread's mirror of the world's rows holding its objects. */ +export type Mirror = { + readonly memory: WebAssembly.Memory; + /** This thread's number, as storage flags name it. */ + readonly thread: number; + /** The log header's offset. */ + readonly block: number; + buffer: ArrayBufferLike; + /** The whole buffer as words, for the layout directory, and signed, for `Atomics.wait`. */ + words: Uint32Array; + signed: Int32Array; + header: Int32Array; + ring: Uint32Array; + ringEntities: Float64Array; + /** The ring's version, -1 before the first rebuild, and its mask. */ + version: number; + mask: number; + /** The next entry to replay, and the sync replayed through, both modulo 2^32. */ + position: number; + through: number; + /** The tick of the sync being replayed. */ + tick: number; + /** Keys of this thread's components by the world's numbers: null for one this thread has no key of. */ + readonly keys: Map; + readonly starts: Map; + archetypes: (Mirrored | null | undefined)[]; + /** Entries copied out of the ring before they're applied. */ + scratch: Uint32Array; + scratchEntities: Float64Array; + staged: Staged[]; + /** The first staged slot not yet settled, by index into `staged`; the next slot's id. */ + head: number; + nextSlot: number; + /** Times it rebuilt, for tests. */ + rebuilds: number; + /** + * How many values of components declared `transfer` are staged and not yet settled, so the thread replays to + * release those whose commands dropped once it's woken. + */ + stagedTransfers: number; + /** + * Runs this thread's finalizers for the stops of every sync through `sync`, before that sync's entries apply, so + * they read its objects at the rows the stops name; after the log lapped, with `old`, which gives an entity's + * objects as they were before the rebuild. False while a finalizer the stops name isn't set up here yet, so + * replay holds before that sync. + */ + finalize: ((sync: number, old: Previously | null) => boolean) | null; +}; + +/** An entity's object before a rebuild, or `start` where it held none there. */ +export type Previously = (entity: number, component: number, start: unknown) => unknown; + +/** A mirror of the rows holding thread `thread`'s objects, read from the log at `block`; it rebuilds first. */ +export const createMirror = (memory: WebAssembly.Memory, block: number, thread: number): Mirror => { + const mirror: Mirror = { + memory, thread, block, buffer: memory.buffer, words: new Uint32Array(0), signed: new Int32Array(0), header: new Int32Array(0), + ring: new Uint32Array(0), ringEntities: new Float64Array(0), version: -1, mask: 0, position: 0, through: 0, tick: 0, + keys: new Map(), starts: new Map(), archetypes: [], scratch: new Uint32Array(1024), scratchEntities: new Float64Array(0), + staged: [], head: 0, nextSlot: 0, rebuilds: 0, stagedTransfers: 0, finalize: null, + }; + refresh(mirror); + return mirror; +}; + +/** Views of the memory again, once it grew; the ring's, once it moved. */ +const refresh = (mirror: Mirror): void => { + // The length in pages the world stored, which this thread's buffer catches up to. + const buffer = mirror.words.length === 0 ? currentBuffer(mirror.memory) : coveringBuffer(mirror.memory, Atomics.load(mirror.words, pagesWord) * pageBytes); + if (buffer.byteLength !== mirror.words.byteLength || buffer !== mirror.buffer) { + mirror.buffer = buffer; + mirror.words = new Uint32Array(buffer); + mirror.signed = new Int32Array(buffer); + mirror.header = new Int32Array(buffer, mirror.block, logHeaderBytes / 4); + mirror.ring = new Uint32Array(0); + } +}; + +const viewRing = (mirror: Mirror): void => { + const { header, buffer } = mirror; + const offset = header[logOffsetWord]; + const capacity = header[logCapacityWord]; + mirror.ring = new Uint32Array(buffer, offset, capacity * entryWords); + mirror.ringEntities = new Float64Array(buffer, offset, capacity * 2); + mirror.mask = capacity - 1; +}; + +/** What a component starts at on this thread: its declaration's default, or `null`. */ +const startOf = (key: symbol | null, defaultOf: (key: symbol) => unknown): unknown => key === null ? null : defaultOf(key); + +/** Names the key of a component this thread fills, by the world's number: null for one it has no key of. */ +export const nameComponent = (mirror: Mirror, component: number, key: symbol | null, defaultOf: (key: symbol) => unknown): void => { + mirror.keys.set(component, key); + mirror.starts.set(component, startOf(key, defaultOf)); +}; + +/** An archetype's record in the directory, by its first word, or -1 before it's entered. */ +const recordOf = (mirror: Mirror, id: number): number => { + const { words } = mirror; + if (id >= Atomics.load(words, tableLengthWord)) return -1; + const offset = words[(words[tableOffsetWord] >> 2) + id]; + return offset === 0 ? -1 : offset >> 2; +}; + +/** + * Columns for an archetype from its record: this thread's components in it, with their keys. Null for one holding none + * of its objects; undefined while a component it holds has no key named yet. + */ +const columnsOf = (mirror: Mirror, record: number): Column[] | null | undefined => { + const { words } = mirror; + const columns: Column[] = []; + let holds = false; + for (let s = 0; s < words[record + recordStorages]; s++) { + const base = record + recordWords + s * storageWords; + const flags = words[base + storageFlags]; + if ((flags & remoteFlag) === 0 || flags >>> threadShift !== mirror.thread) continue; + holds = true; + const component = words[base + storageComponent]; + const key = mirror.keys.get(component); + if (key === undefined) return undefined; + if (key !== null) columns.push({ component, start: mirror.starts.get(component), values: [] }); + } + return holds ? columns : null; +}; + +const mirroredOf = (columns: readonly Column[], capacity: number): Mirrored => { + const byComponent: (Column | undefined)[] = []; + for (const column of columns) byComponent[column.component] = column; + return { columns, byComponent, entities: new Float64Array(Math.max(16, capacity)), count: 0 }; +}; + +/** The mirror of archetype `id`, made from its record the first time an entry names it; undefined while held. */ +const archetypeOf = (mirror: Mirror, id: number): Mirrored | null | undefined => { + const known = mirror.archetypes[id]; + if (known !== undefined) return known; + const record = recordOf(mirror, id); + if (record < 0) return undefined; + const columns = columnsOf(mirror, record); + if (columns === undefined) return undefined; + return mirror.archetypes[id] = columns === null ? null : mirroredOf(columns, 16); +}; + +/** Room for row `row`. */ +const ensureRow = (archetype: Mirrored, row: number): void => { + if (row < archetype.entities.length) return; + let length = archetype.entities.length * 2; + while (length <= row) length *= 2; + const entities = new Float64Array(length); + entities.set(archetype.entities); + archetype.entities = entities; +}; + +/** Row `row` filled with `entity`, every column at its start. */ +const fill = (archetype: Mirrored, row: number, entity: number): void => { + ensureRow(archetype, row); + archetype.entities[row] = entity; + const { columns } = archetype; + for (let c = 0; c < columns.length; c++) { + const column = columns[c]; + column.values[row] = column.start; + } + if (row >= archetype.count) archetype.count = row + 1; +}; + +/** The value of a staged slot, taking it; the start where it was settled already. */ +const take = (mirror: Mirror, slot: number, start: unknown): unknown => { + const at = mirror.head + ((slot - (mirror.staged[mirror.head]?.slot ?? slot)) | 0); + const staged = mirror.staged[at]; + if (staged === undefined || staged.slot !== slot || !staged.live) return start; + staged.live = false; + return staged.value; +}; + +/** Settles staged slots whose sync applied without naming them: their commands dropped, so their values are released. */ +const settle = (mirror: Mirror): void => { + const { staged } = mirror; + let stagedTransfers = 0; + for (let k = mirror.head; k < staged.length; k++) { + const entry = staged[k]; + if (!entry.live) continue; + if (((mirror.through - entry.sync) | 0) < 0) { + if (entry.transfer) stagedTransfers++; + continue; + } + entry.live = false; + if (entry.transfer) releaseValue(entry.value); + } + mirror.stagedTransfers = stagedTransfers; + while (mirror.head < staged.length && !staged[mirror.head].live) mirror.head++; + if (mirror.head === staged.length) { + staged.length = 0; + mirror.head = 0; + } + else if (mirror.head > 1024 && mirror.head * 2 > staged.length) { + staged.splice(0, mirror.head); + mirror.head = 0; + } +}; + +/** + * Stages a value this thread gives one of its objects, for the command that names its slot, applied at sync `sync`; + * returns the slot's id. + */ +export const stage = (mirror: Mirror, entity: number, component: number, value: unknown, sync: number, transfer = false): number => { + const slot = mirror.nextSlot; + mirror.nextSlot = (slot + 1) >>> 0; + mirror.staged.push({ slot, entity, component, value, sync: sync >>> 0, transfer, live: true }); + if (transfer) mirror.stagedTransfers++; + return slot; +}; + +/** + * Applies `count` entries copied into the scratch, in order; returns how many, fewer when a sync's stops can't be + * finalized yet, so it stops before that sync. + */ +const apply = (mirror: Mirror, count: number): number => { + const { scratch, scratchEntities, archetypes } = mirror; + for (let e = 0; e < count; e++) { + const at = e * entryWords; + const first = scratch[at]; + const archetype = first >>> archetypeShift; + switch (first & kindMask) { + case syncEntry: { + // Its stops are finalized against the rows before it applies. + if (mirror.finalize !== null && !mirror.finalize(scratch[at + 1], null)) return e; + mirror.tick = scratch[at + 1] + scratch[at + 2] * 0x100000000; + break; + } + case moveEntry: { + const from = archetypes[archetype]; + const to = archetypes[scratch[at + 2]]; + if (from === null || to === null || from === undefined || to === undefined) break; + const fromRow = scratch[at + 1]; + const toRow = scratch[at + 3]; + ensureRow(to, toRow); + to.entities[toRow] = from.entities[fromRow]; + const { columns } = to; + for (let c = 0; c < columns.length; c++) { + const column = columns[c]; + const source = from.byComponent[column.component]; + column.values[toRow] = source === undefined ? column.start : source.values[fromRow]; + } + if (toRow >= to.count) to.count = toRow + 1; + break; + } + case appendEntry: { + const to = archetypes[archetype]; + if (to !== null && to !== undefined) fill(to, scratch[at + 1], scratch[at + 2] + scratch[at + 3] * 0x100000000); + break; + } + case rangeEntry: { + const to = archetypes[archetype]; + const row = scratch[at + 1]; + const rows = scratch[at + 2]; + if (to !== null && to !== undefined) for (let k = 0; k < rows; k++) fill(to, row + k, scratchEntities[(e + 1) * 2 + k]); + e += Math.ceil(rows / 2); + break; + } + case swapEntry: { + const from = archetypes[archetype]; + if (from === null || from === undefined) break; + const last = scratch[at + 1]; + const row = scratch[at + 2]; + const { columns } = from; + if (last !== row) { + from.entities[row] = from.entities[last]; + for (let c = 0; c < columns.length; c++) { + const column = columns[c]; + column.values[row] = column.values[last]; + } + } + for (let c = 0; c < columns.length; c++) columns[c].values[last] = undefined; + from.count = last; + break; + } + case setEntry: { + const to = archetypes[archetype]; + const column = to === null || to === undefined ? undefined : to.byComponent[scratch[at + 2]]; + const tag = scratch[at + 3]; + const value = tag === startTag ? column?.start : tag === nullTag ? null : take(mirror, (tag - slotTags) >>> 0, column?.start); + if (column !== undefined) column.values[scratch[at + 1]] = value; + break; + } + } + } + return count; +}; + +/** Copies entries `from` to `from + count` out of the ring, wrapping, into the scratch. */ +const copy = (mirror: Mirror, from: number, count: number): void => { + if (mirror.scratch.length < count * entryWords) mirror.scratch = new Uint32Array(Math.max(count * entryWords, mirror.scratch.length * 2)); + const { ring, mask, scratch } = mirror; + const start = from & mask; + const first = Math.min(count, mask + 1 - start); + scratch.set(ring.subarray(start * entryWords, (start + first) * entryWords)); + if (first < count) scratch.set(ring.subarray(0, (count - first) * entryWords), first * entryWords); + if (mirror.scratchEntities.buffer !== scratch.buffer) mirror.scratchEntities = new Float64Array(scratch.buffer, 0, scratch.length / 2); +}; + +/** + * Where the first entry of the first sync naming an archetype this thread can't mirror yet is, among `count` copied, + * so replay stops before that sync; `count` when every one can be. + */ +const heldAt = (mirror: Mirror, count: number): number => { + const { scratch } = mirror; + let syncStart = 0; + for (let e = 0; e < count; e++) { + const first = scratch[e * entryWords]; + const kind = first & kindMask; + if (kind === syncEntry) { + syncStart = e; + continue; + } + if (kind === rangeEntry) { + if (archetypeOf(mirror, first >>> archetypeShift) === undefined) return syncStart; + e += Math.ceil(scratch[e * entryWords + 2] / 2); + continue; + } + if (archetypeOf(mirror, first >>> archetypeShift) === undefined) return syncStart; + if (kind === moveEntry && archetypeOf(mirror, scratch[e * entryWords + 2]) === undefined) return syncStart; + } + return count; +}; + +/** Where the first entry of the first sync after `limit` is, among `count` copied; `count` when there's none. */ +const boundAt = (mirror: Mirror, count: number, limit: number): number => { + const { scratch } = mirror; + for (let e = 0; e < count; e++) { + const first = scratch[e * entryWords]; + const kind = first & kindMask; + if (kind === syncEntry && ((scratch[e * entryWords + 1] - limit) | 0) > 0) return e; + if (kind === rangeEntry) e += Math.ceil(scratch[e * entryWords + 2] / 2); + } + return count; +}; + +/** + * Replays every published sync since the last, in order, up to the mirror's bound: each one's entries, then its staged slots it didn't name are + * settled. Entries are copied out first, and used only if the writer didn't lap them. Lapped, or with the ring + * replaced, it applies nothing: the thread rebuilds. + */ +export const replay = (mirror: Mirror): Replayed => { + refresh(mirror); + const { header } = mirror; + if (Atomics.load(header, logVersionWord) !== mirror.version) return "lapped"; + if (mirror.ring.length === 0) viewRing(mirror); + // While a tail is in flight here, replay stops at its sync. + const held = Atomics.load(header, logBoundedWord) !== 0; + const bound = held ? Atomics.load(header, logBoundWord) >>> 0 : 0; + const latest = Atomics.load(header, logSyncWord) >>> 0; + const bounded = held && ((latest - bound) | 0) > 0; + const sync = bounded ? bound >>> 0 : latest; + const published = Atomics.load(header, logPublishedWord) >>> 0; + const count = (published - mirror.position) >>> 0; + if (count > mirror.mask + 1) return "lapped"; + if (count > 0) { + copy(mirror, mirror.position, count); + // An exchange, so the copy's loads complete before it: the limit then covers whatever the writer overwrote. + const limit = Atomics.add(header, logLimitWord, 0) >>> 0; + if (((limit - mirror.position) >>> 0) > mirror.mask + 1 || Atomics.load(header, logVersionWord) !== mirror.version) return "lapped"; + const usable = bounded ? boundAt(mirror, count, bound) : count; + const applied = apply(mirror, heldAt(mirror, usable)); + mirror.position = (mirror.position + applied) >>> 0; + if (applied < usable) { + // Through the last sync applied, the one before the held one. + if (applied > 0) mirror.through = mirror.tick >>> 0; + settle(mirror); + return "held"; + } + } + // Syncs without entries here may have stops too. + if (mirror.finalize !== null && !mirror.finalize(sync, null)) { + if (count > 0) mirror.through = mirror.tick >>> 0; + settle(mirror); + return "held"; + } + mirror.through = sync; + settle(mirror); + return "current"; +}; + +/** Whether the structure epoch is odd, a sync point applying: readers wait for it to turn even. */ +export const applying = (mirror: Mirror): boolean => (Atomics.load(mirror.signed, structureWord) & 1) === 1; + +/** Waits without blocking until the structure epoch moves on, counted among its waiters so the world notifies it. */ +export const waitStructure = async (mirror: Mirror): Promise => { + const { signed } = mirror; + const epoch = Atomics.load(signed, structureWord); + if ((epoch & 1) === 0) return; + Atomics.add(signed, structureWaitersWord, 1); + try { + if (typeof Atomics.waitAsync !== "function") { + await new Promise(resolve => setTimeout(resolve, 0)); + return; + } + const waited = Atomics.waitAsync(signed, structureWord, epoch); + if (waited.async) await waited.value; + } + finally { + Atomics.sub(signed, structureWaitersWord, 1); + } +}; + +/** Scratch for rebuilds, by entity index: where each entity was among the old rows. */ +type Previous = { entity: Float64Array; archetype: Int32Array; row: Int32Array }; +const previous: Previous = { entity: new Float64Array(0), archetype: new Int32Array(0), row: new Int32Array(0) }; + +/** Indexes the old rows by entity index, for values kept across a rebuild. */ +const indexPrevious = (archetypes: readonly (Mirrored | null | undefined)[], length: number): void => { + if (previous.entity.length < length) { + previous.entity = new Float64Array(length); + previous.archetype = new Int32Array(length); + previous.row = new Int32Array(length); + } + previous.archetype.fill(-1, 0, length); + for (let id = 0; id < archetypes.length; id++) { + const archetype = archetypes[id]; + if (archetype === null || archetype === undefined) continue; + for (let row = 0; row < archetype.count; row++) { + const entity = archetype.entities[row]; + const index = indexOf(entity); + if (index >= length) continue; + previous.entity[index] = entity; + previous.archetype[index] = id; + previous.row[index] = row; + } + } +}; + +/** The value an entity held in a column before the rebuild; `start` where it held none. */ +const previousValue = (old: readonly (Mirrored | null | undefined)[], entity: number, component: number, start: unknown): unknown => { + const index = indexOf(entity); + if (index >= previous.archetype.length || previous.archetype[index] < 0 || previous.entity[index] !== entity) return start; + const column = old[previous.archetype[index]]?.byComponent[component]; + return column === undefined ? start : column.values[previous.row[index]]; +}; + +/** + * Rebuilds every column from the layout directory, as of the last published sync, under the structure epoch: rows + * from each archetype's `entities` and count, values by the tag each row's storage holds, a staged slot's taken, any + * other the thread's own kept by entity, and ticks from the world's. Returns false while a sync point applies, or a + * component it holds has no key named yet, having changed nothing. + */ +export const rebuild = (mirror: Mirror): boolean => { + refresh(mirror); + const { words, signed, header, buffer } = mirror; + const epoch = Atomics.load(signed, structureWord); + if ((epoch & 1) === 1) return false; + const version = Atomics.load(header, logVersionWord); + const sync = Atomics.load(header, logSyncWord) >>> 0; + const published = Atomics.load(header, logPublishedWord) >>> 0; + const old = mirror.archetypes; + const entityLength = new Int32Array(buffer, words[entitiesWord + cursorField], 4)[2]; + /** The old rows, indexed by entity only once a row's value is one this thread gave and kept. */ + let indexed = false; + // Stops through the sync it rebuilds to are finalized first, with the objects as they were. + if (mirror.finalize !== null && !mirror.finalize(sync, (entity, component, start) => { + if (!indexed) { + indexPrevious(old, entityLength); + indexed = true; + } + return previousValue(old, entity, component, start); + })) return false; + const archetypes: (Mirrored | null)[] = []; + /** Staged slots the rebuild takes, settled only once it's complete. */ + const taken: number[] = []; + const tables = Atomics.load(words, tableLengthWord); + for (let id = 0; id < tables; id++) { + const record = recordOf(mirror, id); + if (record < 0) { + archetypes.push(null); + continue; + } + const columns = columnsOf(mirror, record); + if (columns === undefined) return false; + if (columns === null) { + archetypes.push(null); + continue; + } + const count = words[record + recordCount]; + const archetype = mirroredOf(columns, count); + archetype.entities.set(new Float64Array(buffer, words[record + recordEntities], count)); + archetype.count = count; + for (const column of columns) { + const storage = storageOf(words, record, column.component); + const tags = new Uint32Array(buffer, words[storage + storageColumn], count); + column.values = new Array(count).fill(column.start); + for (let row = 0; row < count; row++) { + const tag = tags[row]; + if (tag === startTag) continue; + const entity = archetype.entities[row]; + if (tag === nullTag) column.values[row] = null; + else { + // A value this thread's systems wrote is kept by entity, as one whose slot settled. + const slot = (tag - slotTags) >>> 0; + const staged = tag === ownTag ? undefined : stagedOf(mirror, slot); + if (staged === undefined) { + if (!indexed) { + indexPrevious(old, entityLength); + indexed = true; + } + column.values[row] = previousValue(old, entity, column.component, column.start); + } + else { + column.values[row] = staged.value; + taken.push(slot); + } + } + } + } + archetypes.push(archetype); + } + // An exchange, so every read above completes before it. + if (Atomics.add(signed, structureWord, 0) !== epoch) return false; + for (const slot of taken) { + const staged = stagedOf(mirror, slot); + if (staged !== undefined) staged.live = false; + } + mirror.archetypes = archetypes; + mirror.version = version; + mirror.position = published; + mirror.through = sync; + mirror.ring = new Uint32Array(0); + viewRing(mirror); + settle(mirror); + mirror.rebuilds++; + return true; +}; + +/** A storage's first word in a record, by its component. */ +const storageOf = (words: Uint32Array, record: number, component: number): number => { + for (let s = 0; s < words[record + recordStorages]; s++) { + const base = record + recordWords + s * storageWords; + if (words[base + storageComponent] === component) return base; + } + return -1; +}; + +/** A staged slot's value while no sync applied it, else `settled`. */ +export const stagedValue = (mirror: Mirror, slot: number, settled: unknown): unknown => { + const staged = stagedOf(mirror, slot); + return staged === undefined ? settled : staged.value; +}; + +/** A live staged slot by its id. */ +const stagedOf = (mirror: Mirror, slot: number): Staged | undefined => { + const { staged, head } = mirror; + if (head >= staged.length) return undefined; + const at = head + ((slot - staged[head].slot) | 0); + const entry = staged[at]; + return entry !== undefined && entry.slot === slot && entry.live ? entry : undefined; +}; + +/** For tests: an entity's value in one of this thread's columns by its row, as of the last replay, and its ticks, the world's. */ +export const cellOf = (mirror: Mirror, archetype: number, row: number, component: number): { readonly value: unknown; readonly added: number; readonly changed: number } | null => { + const column = mirror.archetypes[archetype]?.byComponent[component]; + if (column === undefined || row >= (mirror.archetypes[archetype]?.count ?? 0)) return null; + refresh(mirror); + const { words, buffer } = mirror; + const storage = storageOf(words, recordOf(mirror, archetype), component); + const ticked = (words[storage + storageFlags] & tickedFlag) !== 0; + const tickAt = (field: number): number => ticked ? new Float64Array(buffer, words[storage + field], row + 1)[row] : 0; + return { value: column.values[row], added: tickAt(storageAdded), changed: tickAt(storageChanged) }; +}; diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts new file mode 100644 index 000000000..e4c1d3170 --- /dev/null +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { createBuffer, addCommand, pushCommand, pushComponent, removeCommand, type CommandBuffer } from "./buffer.js"; +import { readCell } from "./cells.js"; +import { createCommands } from "./commands.js"; +import { asEntity, indexOf, locate as locateHere } from "./entities.js"; +import { recordCount, structureWord, tableOffsetWord } from "./layout-directory.js"; +import { applying, cellOf, createMirror, nameComponent, rebuild, replay, stage, waitStructure, type Mirror } from "./mirror.js"; +import { createMoveLog, ownTag, slotValue, type MoveLog } from "./move-log.js"; +import { claimThreads } from "./remote.js"; +import { createState, defaultOf, idOf, startTicking, type State } from "./state.js"; +import { applySync, prepareSync, synchronize } from "./synchronize.js"; + +// The move log on one thread: a world whose objects of thread "moveLogTest" a mirror fills, against an inline world given +// the same commands, compared after every sync point by each entity's id. + +const uid = Symbol("moveLogTest/uid"); +const amount = Symbol("moveLogTest/amount"); +const flag = Symbol("moveLogTest/flag"); +const label = Symbol("moveLogTest/label"); +const note = Symbol("moveLogTest/note"); +const own = Symbol("moveLogTest/own"); +declare module "@adobe/ecs" { + export interface Threads { + moveLogTest: true; + } + export interface Components { + [uid]: { type: number }; + [amount]: { type: number }; + [flag]: { type: true }; + [label]: { type: { readonly text: string } | null; thread: "moveLogTest" }; + [note]: { type: Note | null; thread: "moveLogTest" }; + [own]: { type: string }; + } +} +Component.register(uid, { type: { type: "number" } }); +Component.register(amount, { type: { type: "number" } }); +Component.register(flag, { type: { const: true } }); +/** A value that isn't data. */ +type Note = { readonly text: string; readonly read: () => string }; +/** The label's start, which a command on another thread than its own gives it by this value. */ +const none = { text: "none" }; +Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "moveLogTest" }); +Component.register(note, { thread: "moveLogTest" }); +Component.register(own, { type: { type: "string" } }); + +const remoteKeys = [label, note] as const; + +/** A deterministic generator, so a failure replays. */ +const random = (seed: number) => { + let state = seed >>> 0; + return (): number => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +}; + +/** A world whose "moveLogTest" objects a mirror on this thread fills, its log holding at least `minimum` entries. */ +const threaded = (minimum: number): { readonly state: State; readonly mirror: Mirror; readonly log: MoveLog } => { + const state = createState(true); + let mirror: Mirror | null = null; + const log = createMoveLog(state.memory, 1, state.starts, component => { + const key = state.keys[component]; + if (mirror !== null && typeof key === "symbol") nameComponent(mirror, component, key, defaultOf); + }, minimum); + mirror = createMirror(state.memory.memory, log.block, 1); + for (const key of [uid, amount, flag, own, ...remoteKeys]) idOf(state, key); + claimThreads(state, log, ["moveLogTest"]); + return { state, mirror, log }; +}; + +const inline = (): State => { + const state = createState(true); + for (const key of [uid, amount, flag, own, ...remoteKeys]) idOf(state, key); + // As the threaded world keeps them for the thread's objects. + startTicking(state, remoteKeys.map(key => idOf(state, key))); + return state; +}; + +/** Brings the mirror up to the last sync point, rebuilding when its log lapped it. */ +const follow = (mirror: Mirror): void => { + const replayed = replay(mirror); + if (replayed === "lapped") expect(rebuild(mirror)).toBe(true); + else expect(replayed).toBe("current"); +}; + +/** Every live entity of a world with its archetype and row. */ +const liveRows = (state: State): { readonly entity: number; readonly archetype: number; readonly row: number }[] => + state.archetypes.flatMap(archetype => archetype.resource ? [] : Array.from({ length: archetype.count }, (_, row) => ({ entity: archetype.entities[row], archetype: archetype.id, row }))); + +/** The id an entity's row holds. */ +const idAt = (state: State, archetype: number, row: number): unknown => { + const storage = state.archetypes[archetype].storageById[idOf(state, uid)]; + return storage === undefined ? undefined : readCell(storage, row); +}; + +/** The inline world's objects and ticks against the mirror's, by entity id, and the threaded world's rows against the inline's. */ +const compare = (reference: State, world: State, mirror: Mirror): number => { + let compared = 0; + for (const { entity, archetype, row } of liveRows(reference)) { + const index = locateHere(world.entities, entity); + expect(index).toBeGreaterThanOrEqual(0); + expect([world.entities.archetype[index], world.entities.row[index]]).toEqual([archetype, row]); + const storages = reference.archetypes[archetype]; + expect(idAt(world, archetype, row)).toBe(idAt(reference, archetype, row)); + for (const key of remoteKeys) { + const id = idOf(reference, key); + const storage = storages.storageById[id]; + const cell = cellOf(mirror, archetype, row, id); + if (storage === undefined) { + expect(cell).toBeNull(); + continue; + } + expect(cell).toEqual({ value: readCell(storage, row), added: storage.added[row], changed: storage.changed[row] }); + // The world holds only tags of the thread's objects. + expect(world.archetypes[archetype].storageById[id]?.values.length).toBe(0); + compared++; + } + } + return compared; +}; + +/** World commands for both worlds, issued the same way, through the commands systems get. */ +const worldCommands = (state: State) => { + const target = { buffer: createBuffer() }; + return { commands: createCommands(state, target), target }; +}; + +/** + * Random spawns, despawns, adds and removes, single and list forms, from the world's thread, which give the thread's + * objects only their start or null; then the thread's own values for its objects, staged by the mirror, and world + * commands after those, all applied as one sync point in both worlds. + */ +const property = (seed: number, minimum: number, steps: number): { readonly compared: number; readonly rebuilds: number } => { + const next = random(seed); + const pick = (list: readonly T[]): T => list[Math.floor(next() * list.length)]; + const { state: world, mirror } = threaded(minimum); + const reference = inline(); + let nextUid = 1; + let compared = 0; + let dead: number[] = []; + follow(mirror); + for (let step = 0; step < steps; step++) { + const before = { here: worldCommands(world), there: worldCommands(reference) }; + const after = { here: worldCommands(world), there: worldCommands(reference) }; + const thread: { here: CommandBuffer; there: CommandBuffer } = { here: createBuffer(), there: createBuffer() }; + const live = liveRows(reference).map(({ entity }) => entity); + // Now and then one that's dead, whose commands drop. + const someone = () => asEntity(live.length === 0 || (dead.length > 0 && next() < 0.1) ? pick(dead.length > 0 ? dead : [0]) : pick(live)); + const both = (issue: (commands: ReturnType) => void, phase: typeof before): void => { + issue(phase.here.commands); + issue(phase.there.commands); + }; + const sync = world.tick + 1; + const operations = 1 + Math.floor(next() * 12); + for (let o = 0; o < operations; o++) { + const phase = next() < 0.7 ? before : after; + const choice = next(); + const id = nextUid; + if (choice < 0.2) { + nextUid++; + const form = Math.floor(next() * 4); + const value = next() < 0.5 ? undefined : null; + both(commands => { + if (form === 0) commands.spawn([uid, label])({ [uid]: id, [label]: value }); + else if (form === 1) commands.spawn([uid, amount, label, note])({ [uid]: id, [amount]: 1, [note]: value }); + else if (form === 2) commands.spawn([uid, flag])({ [uid]: id }); + else commands.spawn([uid, note, own])({ [uid]: id, [own]: "o" }); + }, phase); + } + else if (choice < 0.3) { + const entity = someone(); + both(commands => commands.despawn(entity), phase); + } + else if (choice < 0.55) { + const entity = someone(); + const form = Math.floor(next() * 4); + both(commands => { + if (form === 0) commands.add(entity, label, none); + else if (form === 1) commands.add(entity, note, null); + else if (form === 2) commands.add(entity, [amount, label], { [amount]: 2 }); + else commands.add(entity, [flag, note, label], { [label]: null }); + }, phase); + } + else if (choice < 0.75) { + const entity = someone(); + const form = Math.floor(next() * 3); + both(commands => { + if (form === 0) commands.remove(entity, label); + else if (form === 1) commands.remove(entity, [note, amount]); + else commands.remove(entity, [flag, label, note]); + }, phase); + } + else { + // The thread's own value for its object, staged there under a slot the world's command names. + const entity = someone(); + const key = pick(remoteKeys); + const text = `v${step}.${o}`; + const value = key === label ? { text } : { text, read: () => text }; + const component = idOf(world, key); + pushCommand(thread.here, addCommand, entity); + pushComponent(thread.here, component, slotValue(stage(mirror, entity, component, value, sync))); + pushCommand(thread.there, addCommand, entity); + pushComponent(thread.there, idOf(reference, key), value); + if (next() < 0.2) { + // And taken away again in the same sync point, by the thread itself. + pushCommand(thread.here, removeCommand, entity); + pushComponent(thread.here, component, undefined); + pushCommand(thread.there, removeCommand, entity); + pushComponent(thread.there, idOf(reference, key), undefined); + } + } + } + for (const [state, buffers] of [[world, [before.here.target.buffer, thread.here, after.here.target.buffer]], [reference, [before.there.target.buffer, thread.there, after.there.target.buffer]]] as const) { + for (const buffer of buffers) state.queued.push({ buffer, guarded: true }); + synchronize(state); + } + dead = [...dead, ...live.filter(entity => locateHere(reference.entities, entity) < 0)].slice(-20); + follow(mirror); + compared += compare(reference, world, mirror); + // Every staged slot is settled once its sync point applied. + expect(mirror.staged.slice(mirror.head).filter(entry => entry.live)).toEqual([]); + } + return { compared, rebuilds: mirror.rebuilds }; +}; + +describe("the move log", () => { + it("gives a joined thread's columns the inline world's values and ticks after every sync point, replaying its log", () => { + for (const seed of [1, 2, 3, 4]) { + const { compared, rebuilds } = property(seed, 4096, 150); + expect(compared).toBeGreaterThan(100); + // Only the first, at the claim. + expect(rebuilds).toBe(1); + } + }); + it("rebuilds to the same values and ticks once the log drops entries a thread didn't replay", () => { + for (const seed of [5, 6, 7, 8]) { + const { compared, rebuilds } = property(seed, 4, 150); + expect(compared).toBeGreaterThan(100); + expect(rebuilds).toBeGreaterThan(10); + } + }); + it("keeps by entity a value the thread's systems wrote, which its row's tag marks, across a rebuild and a move", () => { + const { state, mirror } = threaded(4); + follow(mirror); + const { commands, target } = worldCommands(state); + const entity = commands.spawn([uid, label])({ [uid]: 1 }); + const other = commands.spawn([uid, label])({ [uid]: 2 }); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + follow(mirror); + // A system on the thread writes the entity's label: its value there, its tag the thread's own. + const id = idOf(state, label); + const index = indexOf(entity); + const archetype = state.archetypes[state.entities.archetype[index]]; + const row = state.entities.row[index]; + const column = mirror.archetypes[archetype.id]?.byComponent[id]; + if (column === undefined) throw new Error("The mirror holds no labels."); + column.values[row] = { text: "written" }; + const storage = archetype.storageById[id]; + if (storage === undefined) throw new Error("The world holds no labels."); + storage.tags[row] = ownTag; + // More entries than the log holds, and a move: the thread rebuilds, keeping the value by entity. + const rebuilds = mirror.rebuilds; + commands.add(entity, flag); + for (let k = 0; k < 4; k++) commands.add(other, amount, k); + commands.despawn(other); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + follow(mirror); + expect(mirror.rebuilds).toBe(rebuilds + 1); + const moved = indexOf(entity); + expect(cellOf(mirror, state.entities.archetype[moved], state.entities.row[moved], id)?.value).toEqual({ text: "written" }); + }); + it("drops a staged slot whose command names an entity the sync point despawned, without a value", () => { + const { state, mirror } = threaded(4096); + follow(mirror); + const { commands, target } = worldCommands(state); + const entity = commands.spawn([uid, label])({ [uid]: 1 }); + synchronize(state); + commands.despawn(entity); + const buffer = createBuffer(); + pushCommand(buffer, addCommand, entity); + pushComponent(buffer, idOf(state, label), slotValue(stage(mirror, entity, idOf(state, label), { text: "dropped" }, state.tick + 1))); + state.queued.push({ buffer: target.buffer, guarded: true }, { buffer, guarded: true }); + synchronize(state); + follow(mirror); + expect(mirror.staged).toEqual([]); + }); + it("shows the thread a sync point applying through the structure epoch, waiting without blocking until it's applied", async () => { + const { state, mirror } = threaded(4096); + const { commands, target } = worldCommands(state); + const entity = commands.spawn([uid, label])({ [uid]: 1 }); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + expect(applying(mirror)).toBe(false); + commands.add(entity, amount, 3); + state.queued.push({ buffer: target.buffer, guarded: true }); + expect(prepareSync(state)).toBe(true); + expect(new Int32Array(state.memory.memory.buffer)[structureWord] & 1).toBe(1); + expect(applying(mirror)).toBe(true); + let waited = false; + const waiting = waitStructure(mirror).then(() => { waited = true; }); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(waited).toBe(false); + applySync(state); + await waiting; + expect(applying(mirror)).toBe(false); + }); + it("keeps the counts of archetypes holding the thread's objects in the layout directory", () => { + const { state } = threaded(4096); + const { commands, target } = worldCommands(state); + for (let k = 0; k < 5; k++) commands.spawn([uid, label])({ [uid]: k }); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + const archetype = state.archetypes.find(candidate => candidate.count === 5); + if (archetype === undefined) throw new Error("No archetype holds the spawns."); + const words = new Uint32Array(state.memory.memory.buffer); + const record = words[words[tableOffsetWord] / 4 + archetype.id] / 4; + expect(words[record + recordCount]).toBe(5); + commands.despawn(asEntity(archetype.entities[0])); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + expect(words[record + recordCount]).toBe(4); + }); +}); diff --git a/packages/ecs/src/implementation/world/move-log.ts b/packages/ecs/src/implementation/world/move-log.ts new file mode 100644 index 000000000..de46fb46a --- /dev/null +++ b/packages/ecs/src/implementation/world/move-log.ts @@ -0,0 +1,215 @@ +import type { Archetype } from './archetype.js'; +import type { Memory } from './memory.js'; + +// The move log: how a joined thread's object columns follow the world's rows. For archetypes holding objects a joined +// thread fills, every path that moves or fills a row appends, in apply order, entries the thread replays on its own +// columns. Shared by the world's thread, which writes it, and the joined thread, which reads it (`mirror.ts`). + +/** + * The log's header, a block of its own that never moves: the ring's version, bumped when it's replaced, its offset + * and capacity in entries, a power of two; the position the writer may have written up to; the position published + * through, and the sync published through, modulo 2^32; then the thread's first stop list and the sync it finalized + * through; a tail's bound; then the runs finished. Positions count entries from the start, modulo 2^32. + */ +export const logVersionWord = 0; +export const logOffsetWord = 1; +export const logCapacityWord = 2; +export const logLimitWord = 3; +export const logPublishedWord = 4; +export const logSyncWord = 5; +/** The offset of the thread's first stop list (`stop-lists.ts`), 0 before one is written. */ +export const logStopsWord = 6; +/** The sync the thread finalized through, which it stores once its finalizers ran for every stop through it. */ +export const logFinalizedWord = 7; +/** + * While a tail is in flight on the thread, 1, and the sync it names, which the thread's replay stops at so the tail's + * rows stay those of that sync; the world stores them as it posts the tail and clears the flag once it's done. + */ +export const logBoundedWord = 8; +export const logBoundWord = 9; +/** The runs and ticks whose tiers the world finished, modulo 2^32: a joined thread returns to its event loop once each. */ +export const logRunWord = 10; +export const logHeaderBytes = 64; + +/** An entry is four words; its first holds its kind in the low four bits, the added flag, and an archetype from bit 8. */ +export const entryWords = 4; +export const kindMask = 15; +export const addedFlag = 16; +export const archetypeShift = 8; + +/** A sync's first entry for the thread: its tick, low and high words. */ +export const syncEntry = 1; +/** A row moving between archetypes both holding the thread's objects: from archetype and row, to archetype and row. */ +export const moveEntry = 2; +/** A row filled in an archetype holding the thread's objects, every one of them at its start: its row and entity. */ +export const appendEntry = 3; +/** Rows filled from `row`, `count` of them, each at its starts, followed by their entities, two to an entry. */ +export const rangeEntry = 4; +/** A row removed, the last row moved into it: the last row, then the row; equal when the removed row was last. */ +export const swapEntry = 5; +/** A value given to the thread's object in a row: the row, the component, and its tag; added when it starts there. */ +export const setEntry = 6; + +/** + * Tags: the component's start, `null`, a value the thread's systems wrote, which it keeps by entity, or a staged slot's + * value, by the slot's id plus `slotTags`. + */ +export const startTag = 0; +export const nullTag = 1; +export const ownTag = 2; +export const slotTags = 3; + +/** Entries a log holds at least. */ +export const minimumEntries = 4096; +/** Entries the writer may write past what it last stored as its limit. */ +const limitStep = 64; + +/** A value the thread that fills a component gave it, staged there under its slot's id: no other thread holds one. */ +export type SlotValue = { readonly [slotOf]: number }; +const slotOf = Symbol("slot"); + +export const slotValue = (slot: number): SlotValue => ({ [slotOf]: slot }); + +/** A value's tag in its storage, or -1 for a value another thread can't give an object it doesn't hold. */ +export const tagOf = (value: unknown, start: unknown): number => { + if (value === start || value === undefined) return startTag; + if (value === null) return nullTag; + return typeof value === "object" && slotOf in value ? (Number(Reflect.get(value, slotOf)) + slotTags) >>> 0 : -1; +}; + +/** The world's end of a joined thread's log. */ +export type MoveLog = { + /** The joined thread's number, which storage flags in the layout directory name. */ + readonly thread: number; + readonly memory: Memory; + readonly header: Int32Array; + /** The header block's offset, which the thread is posted. */ + readonly block: number; + ring: Uint32Array; + ringEntities: Float64Array; + mask: number; + /** The next entry's position, and the limit last stored. */ + position: number; + limit: number; + /** The current sync's tick, and whether its sync entry is written. */ + tick: number; + headed: boolean; + /** By component id, what each starts at, as the world resolves it. */ + readonly starts: readonly unknown[]; + /** Archetypes holding the thread's objects, whose rows size the ring. */ + readonly archetypes: Archetype[]; + readonly minimum: number; + /** Tells the thread a component it fills, once the world met it. */ + readonly announce: (component: number) => void; +}; + +const powerOfTwo = (entries: number): number => 2 ** Math.ceil(Math.log2(Math.max(entries, 1))); + +const placeRing = (log: MoveLog, capacity: number): void => { + const { memory, header } = log; + const offset = memory.allocate(capacity * entryWords * 4); + const buffer = memory.memory.buffer; + log.ring = new Uint32Array(buffer, offset, capacity * entryWords); + log.ringEntities = new Float64Array(buffer, offset, capacity * 2); + log.mask = capacity - 1; + header[logOffsetWord] = offset; + header[logCapacityWord] = capacity; + Atomics.add(header, logVersionWord, 1); +}; + +/** A log in shared memory for joined thread `thread`, its ring holding `minimum` entries at least. */ +export const createMoveLog = (memory: Memory, thread: number, starts: readonly unknown[], announce: (component: number) => void, minimum = minimumEntries): MoveLog => { + const block = memory.allocate(logHeaderBytes); + const header = new Int32Array(memory.memory.buffer, block, logHeaderBytes / 4).fill(0); + const log: MoveLog = { + thread, memory, header, block, ring: new Uint32Array(0), ringEntities: new Float64Array(0), mask: 0, position: 0, limit: 0, + tick: -1, headed: false, starts, archetypes: [], minimum: powerOfTwo(minimum), announce, + }; + placeRing(log, log.minimum); + return log; +}; + +/** Returns the log's blocks to the memory, once its thread left: a thread that died may still be reading it. */ +export const freeMoveLog = (log: MoveLog): void => { + log.memory.free(log.header[logOffsetWord], (log.mask + 1) * entryWords * 4); + log.memory.free(log.block, logHeaderBytes); +}; + +/** + * At the start of a sync point: its tick, and a ring holding the rows of the archetypes with the thread's objects, + * replaced while it's too small. The old block is freed, so a thread reading it sees the version move, and rebuilds. + */ +export const beginLog = (log: MoveLog, tick: number): void => { + log.tick = tick; + log.headed = false; + let rows = 0; + for (const archetype of log.archetypes) rows += archetype.count; + if (rows <= log.mask + 1) return; + const old = log.header[logOffsetWord]; + const oldBytes = (log.mask + 1) * entryWords * 4; + placeRing(log, Math.max(log.minimum, powerOfTwo(rows))); + log.memory.free(old, oldBytes); +}; + +/** Stores a limit past the next entries before writing them, so a reader copying older entries can tell they lapped. */ +const extendLimit = (log: MoveLog): void => { + log.limit = (log.position + limitStep) >>> 0; + Atomics.store(log.header, logLimitWord, log.limit); +}; + +const put = (log: MoveLog, first: number, second: number, third: number, fourth: number): void => { + if (log.position === log.limit) extendLimit(log); + const at = (log.position & log.mask) * entryWords; + const { ring } = log; + ring[at] = first; + ring[at + 1] = second; + ring[at + 2] = third; + ring[at + 3] = fourth; + log.position = (log.position + 1) >>> 0; +}; + +/** An entry, after the sync's own the first time. */ +const entry = (log: MoveLog, first: number, second: number, third: number, fourth: number): void => { + if (!log.headed) { + log.headed = true; + put(log, syncEntry, log.tick >>> 0, Math.floor(log.tick / 0x100000000), 0); + } + put(log, first, second, third, fourth); +}; + +export const logMove = (log: MoveLog, from: number, fromRow: number, to: number, toRow: number): void => + entry(log, moveEntry | (from << archetypeShift), fromRow, to, toRow); + +export const logAppend = (log: MoveLog, archetype: number, row: number, entity: number): void => + entry(log, appendEntry | (archetype << archetypeShift), row, entity >>> 0, Math.floor(entity / 0x100000000)); + +/** Rows `row` to `row + count` filled with `entities[first]` on. */ +export const logRange = (log: MoveLog, archetype: number, row: number, count: number, entities: Float64Array, first: number): void => { + entry(log, rangeEntry | (archetype << archetypeShift), row, count, 0); + for (let k = 0; k < count; k += 2) { + put(log, 0, 0, 0, 0); + const at = ((log.position - 1) & log.mask) * 2; + log.ringEntities[at] = entities[first + k]; + log.ringEntities[at + 1] = k + 1 < count ? entities[first + k + 1] : 0; + } +}; + +export const logSwap = (log: MoveLog, archetype: number, last: number, row: number): void => + entry(log, swapEntry | (archetype << archetypeShift), last, row, 0); + +export const logSet = (log: MoveLog, archetype: number, row: number, component: number, tag: number, added: boolean): void => + entry(log, setEntry | (added ? addedFlag : 0) | (archetype << archetypeShift), row, component, tag); + +/** At the end of a sync point: its entries, then the sync, published, each by a store the thread loads. */ +export const publishLog = (log: MoveLog): void => { + if (log.tick < 0) return; + if (log.headed) Atomics.store(log.header, logPublishedWord, log.position); + Atomics.store(log.header, logSyncWord, log.tick >>> 0); + log.tick = -1; + log.headed = false; +}; + +/** Once a run's or tick's tiers are done, before its sync point and tails. */ +export const markRun = (log: MoveLog): void => { + Atomics.add(log.header, logRunWord, 1); +}; diff --git a/packages/ecs/src/implementation/world/names.test.ts b/packages/ecs/src/implementation/world/names.test.ts new file mode 100644 index 000000000..9d52031a2 --- /dev/null +++ b/packages/ecs/src/implementation/world/names.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { identify } from "../../interface/registry/ids.test-helper.js"; +import { workers } from "../../interface/resources/workers.js"; +import { Rule } from "../../interface/rule/rule.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { count } from "./reads.test-helper.js"; +import { runReported } from "./reports.test-helper.js"; + +const value = Symbol("namesTest/value"); +const other = Symbol("namesTest/other"); +const first = Symbol("namesTest/first"); +const second = Symbol("namesTest/second"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [other]: { type: number }; + [first]: { type: true; derived: true }; + [second]: { type: true; derived: true }; + } +} + +Component.register(value, { type: { type: "number" } }); +Component.register(other, { type: { type: "number" } }); +Component.register(first, { type: { const: true }, derived: true }); +Component.register(second, { type: { const: true }, derived: true }); + +// Two libraries, written apart, both name a rule, a finalizer and a system "linked". +Rule.create({ name: "namesTest/linked", components: { has: [value] }, derive: [first] }); +Rule.create({ name: "namesTest/linked", components: { has: [value] }, derive: [second] }); +const released: string[] = []; +let failing = false; +Finalizer.create({ name: "namesTest/linked", components: { read: [value] } }, row => { released.push(`first ${row[value]}`); }); +Finalizer.create({ name: "namesTest/linked", components: { read: [value] } }, row => { released.push(`second ${row[value]}`); if (failing) throw new Error("second failed"); }); + +const ran: string[] = []; +const spawn = System.create({ name: "namesTest/spawn" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); }); +const despawn = System.create({ name: "namesTest/despawn", components: { has: [value] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const failOnce = System.create(() => { throw new Error("unnamed"); }); + +describe("systems, rules and finalizers of one name", () => { + it("are distinct in schedules, both run, and are told apart in reports by their labels", async () => { + const a = System.create({ name: "namesTest/linked", components: { write: [value] } }, row => { ran.push("a"); row[value] += 1; }); + const b = System.create({ name: "namesTest/linked", components: { write: [value] }, after: [a] }, row => { ran.push("b"); row[value] += 1; throw new Error("b failed"); }); + const c = System.create({ name: "namesTest/linked", components: { read: [value] }, after: [b] }, row => { ran.push(`c${row[value]}`); }); + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn); + const report = await runReported(world, System.create({ name: "namesTest/frame", systems: [c, b, a] })); + expect(ran).toEqual(["a", "b"]); + expect(report.failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "system", name: "namesTest/linked#6", message: "b failed" }]); + expect(report.skipped).toEqual(["namesTest/linked#7"]); + }); + it("both apply, as rules and as finalizers", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn); + expect(await count(world, { required: [value, first, second] })).toBe(1); + released.length = 0; + failing = true; + const report = await runReported(world, despawn); + failing = false; + expect(released).toEqual(["first 1", "second 1"]); + expect(report.failures.map(({ kind, name }) => ({ kind, name }))).toEqual([{ kind: "finalizer", name: "namesTest/linked#4" }]); + }); + it("are labeled by name and id where the plugin gave ids", async () => { + const write = (id: string) => System.create(identify({ name: "namesTest/library", components: { write: [other] } }, id), row => { row[other] = 1; throw new Error(id); }); + const one = write("one.ts#library"); + const two = write("two.ts#library"); + await using world = createWorld({ [workers]: 0 }); + await world.run(System.create({ name: "namesTest/spawnOther" }, ({ commands }) => { commands.spawn([other])({ [other]: 0 }); })); + const names = async (system: System) => (await runReported(world, system)).failures.map(failure => failure.name); + expect([await names(one), await names(two)]).toEqual([["namesTest/library (one.ts#library)"], ["namesTest/library (two.ts#library)"]]); + }); + it("label a system without a name by its binding and the id the plugin gave it", async () => { + await using world = createWorld({ [workers]: 0 }); + expect((await runReported(world, failOnce)).failures.map(failure => failure.name)).toEqual(["failOnce (@adobe/ecs/src/implementation/world/names.test.ts#failOnce)"]); + }); +}); diff --git a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts new file mode 100644 index 000000000..7eecf4cc6 --- /dev/null +++ b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; +import { Observe } from "../../observe/index.js"; +import { settle } from "./observe.test-helper.js"; + +// The observer factories of `src/observe` over values GPU kernels wrote. + +const { entity, select, count, toPromise } = Observe; + +const heat = Symbol("observeCasesGpuTest/heat"); +const rate = Symbol("observeCasesGpuTest/rate"); + +declare module "@adobe/ecs" { + export interface Components { + [heat]: { type: number }; + [rate]: { type: number }; + } +} + +Component.register(heat, { type: { type: "number", precision: 1 } }); +Component.register(rate, { type: { type: "number", precision: 1 } }); + +let spawned: Entity[] = []; +const spawn = System.create({ name: "observeCasesGpuTest/spawn" }, ({ commands }) => { + spawned = []; + for (let i = 0; i < 100; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 })); +}); +const warm = System.create({ name: "observeCasesGpuTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { + row[heat] = row[heat] + row[rate] * 100; +}); + +const idle = System.create({ name: "observeCasesGpuTest/idle" }, () => {}); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +describe("observers of GPU kernels' writes", () => { + it("give an entity's values the GPU wrote, read back after each run, and a first value once read back", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + await world.run(spawn); + const seen: unknown[] = []; + const stop = entity(world, spawned[1], { required: [heat] })(value => seen.push(value)); + await settle(world); + expect(seen).toEqual([{ [heat]: 1 }]); + await world.run(warm); + expect(seen).toEqual([{ [heat]: 1 }, { [heat]: 101 }]); + stop(); + // Nothing observes heat during this run, so its writes stay on the GPU, and a first value waits for the readback. + await world.run(warm); + const later: unknown[] = []; + entity(world, spawned[2], { required: [heat] })(value => later.push(value)); + expect(await toPromise(entity(world, spawned[3], { required: [heat] }))).toEqual({ [heat]: 203 }); + await settle(world); + expect(later).toEqual([{ [heat]: 202 }]); + await world.dispose(); + }); + + it("give a query's result over values the GPU wrote", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + await world.run(spawn); + const counts: number[] = []; + count(world, { required: [heat], where: { [heat]: { ">=": 100 } } })(value => counts.push(value)); + await settle(world); + await world.run(warm); + expect(counts).toEqual([0, 100]); + await world.run(warm); + const hot = await toPromise(select(world, { required: [heat], where: { [heat]: { ">=": 250 } } })); + expect(new Set(hot)).toEqual(new Set(spawned.slice(50))); + await world.dispose(); + }); + + it("give an observer waiting for its first value nothing from a run queued before it", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + await world.run(spawn); + await world.run(warm); + const running = world.run(warm); + const seen: unknown[] = []; + entity(world, spawned[0], { required: [heat] })(value => seen.push(value)); + const counts: number[] = []; + count(world, { required: [heat], where: { [heat]: { ">=": 50 } } })(value => counts.push(value)); + await running; + // Queued after the first values, so they've come by its end. + await world.run(idle); + await settle(world); + expect(seen).toEqual([{ [heat]: 200 }]); + expect(counts).toEqual([100]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts new file mode 100644 index 000000000..a6d061e2a --- /dev/null +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -0,0 +1,798 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "../index.js"; +import { Observe } from "../../observe/index.js"; +import { settle } from "./observe.test-helper.js"; +import { fillReports, runReported, runThenNext } from "./reports.test-helper.js"; +import { retainedRuns } from "./reports.js"; +import { Report } from "../../interface/report/report.js"; + +// The observer factories of `src/observe`. Each case subscribes, settles, then expects, so it holds whenever a first +// value comes. + +const level = Symbol("observeCasesTest/level"); +const other = Symbol("observeCasesTest/other"); +const total = Symbol("observeCasesTest/total"); +const weight = Symbol("observeCasesTest/weight"); + +declare module "@adobe/ecs" { + interface Resources { + [level]: { type: number }; + [other]: { type: number }; + [total]: { type: number; reduce: "+" }; + } + interface Components { + [weight]: { type: number }; + } +} + +Resource.register(level, { type: { type: "number", default: 0 } }); +Resource.register(other, { type: { type: "number", default: 0 } }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Component.register(weight, { type: { type: "number" } }); + +const raise = System.create({ name: "observeCasesTest/raise", input: { type: "object", properties: { by: { type: "number" } } }, resources: { write: [level] } }, ({ input, resources }) => { + resources[level] += input.by; + resources[level] += input.by; +}); +const touchOther = System.create({ name: "observeCasesTest/touchOther", resources: { write: [other] } }, ({ resources }) => { resources[other] += 1; }); +const spawn = System.create({ name: "observeCasesTest/spawn" }, ({ commands }) => { commands.spawn([weight])({ [weight]: 2 }); }); +const sum = System.create({ name: "observeCasesTest/sum", components: { read: [weight] }, reduce: { [total]: "+" } }, (row, { reduce }) => { reduce[total](row[weight]); }); +const keep = System.create({ name: "observeCasesTest/keep", resources: { write: [level] } }, ({ resources }) => { resources[level] = resources[level] + 0; }); + +/** Errors thrown out of band while `catching` runs, as uncaught errors would be, caught instead. */ +const catching = async (run: () => Promise): Promise => { + const caught: unknown[] = []; + const original = globalThis.queueMicrotask; + vi.spyOn(globalThis, "queueMicrotask").mockImplementation(callback => original(() => { + try { + callback(); + } + catch (error) { + caught.push(error); + } + })); + try { + await run(); + } + finally { + vi.restoreAllMocks(); + } + return caught; +}; + +afterEach(() => { vi.restoreAllMocks(); }); + +const heat = Symbol("observeCasesTest/heat"); +const tag = Symbol("observeCasesTest/tag"); +const flag = Symbol("observeCasesTest/flag"); +const filler = Symbol("observeCasesTest/filler"); + +declare module "@adobe/ecs" { + interface Components { + [heat]: { type: number }; + [tag]: { type: true }; + [flag]: { type: true }; + [filler]: { type: number }; + } +} + +Component.register(heat, { type: { type: "number" } }); +Component.register(tag, { type: { const: true } }); +Component.register(flag, { type: { const: true } }); +Component.register(filler, { type: { type: "number" } }); + +/** The entity the systems below act on, set by `create`. */ +let target = Entity.none; +const create = System.create({ name: "observeCasesTest/create" }, ({ commands }) => { target = commands.spawn([weight, heat])({ [weight]: 1, [heat]: 0 }); }); +const despawn = System.create({ name: "observeCasesTest/despawn" }, ({ commands }) => { commands.despawn(target); }); +const weigh = System.create({ name: "observeCasesTest/weigh", components: { write: [weight], not: [filler] } }, row => { + row[weight] += 1; + row[weight] += 1; +}); +const warm = System.create({ name: "observeCasesTest/warm", components: { write: [heat] } }, row => { row[heat] += 1; }); +const double = System.create({ name: "observeCasesTest/double", kernel: "cpu", components: { write: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); +const addTag = System.create({ name: "observeCasesTest/addTag" }, ({ commands }) => { commands.add(target, tag, true); }); +const removeTag = System.create({ name: "observeCasesTest/removeTag" }, ({ commands }) => { commands.remove(target, tag); }); +const removeWeight = System.create({ name: "observeCasesTest/removeWeight" }, ({ commands }) => { commands.remove(target, weight); }); +const addFlag = System.create({ name: "observeCasesTest/addFlag" }, ({ commands }) => { commands.add(target, flag, true); }); + +const score = Symbol("observeCasesTest/score"); +const benched = Symbol("observeCasesTest/benched"); +const marker = Symbol("observeCasesTest/marker"); + +declare module "@adobe/ecs" { + interface Components { + [score]: { type: number }; + [benched]: { type: true }; + [marker]: { type: true }; + } +} + +Component.register(score, { type: { type: "number" } }); +Component.register(benched, { type: { const: true } }); +Component.register(marker, { type: { const: true } }); + +/** Players the systems below act on, in spawn order, set by `players`. */ +let roster: Entity[] = []; +const players = System.create({ name: "observeCasesTest/players" }, ({ commands }) => { + roster = [commands.spawn([score])({ [score]: 1 }), commands.spawn([score])({ [score]: 2 }), commands.spawn([score, benched])({ [score]: 3, [benched]: true })]; +}); +const join = System.create({ name: "observeCasesTest/join" }, ({ commands }) => { roster.push(commands.spawn([score])({ [score]: 0 })); }); +const bench = System.create({ name: "observeCasesTest/bench" }, ({ commands }) => { commands.add(roster[0], benched, true); }); +/** The first player leaves the bench as the last joins it, so as many match as before. */ +const swap = System.create({ name: "observeCasesTest/swap" }, ({ commands }) => { + commands.add(roster[0], benched, true); + commands.remove(roster[2], benched); +}); +const mark = System.create({ name: "observeCasesTest/mark" }, ({ commands }) => { commands.add(roster[0], marker, true); }); +const leave = System.create({ name: "observeCasesTest/leave" }, ({ commands }) => { commands.despawn(roster[1]); }); +const score10 = System.create({ name: "observeCasesTest/score10", components: { write: [score] } }, row => { row[score] += 10; }); +const nudge = System.create({ name: "observeCasesTest/nudge", components: { write: [score] } }, row => { row[score] += 0; }); +const invert = System.create({ name: "observeCasesTest/invert", components: { write: [score] } }, row => { row[score] = -row[score]; }); +const invertKernel = System.create({ name: "observeCasesTest/invertKernel", kernel: "cpu", components: { write: [score] } }, row => { row[score] = 0 - row[score]; }); +/** Swaps the bench like `swap`, then despawns more entities than the journal holds, so it drops the swap's entries. */ +const swapAndCrowd = System.create({ name: "observeCasesTest/swapAndCrowd" }, ({ commands }) => { + commands.add(roster[0], benched, true); + commands.remove(roster[2], benched); + for (const spawned of crowded.splice(0)) commands.despawn(spawned); +}); +const crowded: Entity[] = []; +const crowdUp = System.create({ name: "observeCasesTest/crowdUp" }, ({ commands }) => { + for (let i = 0; i < 5000; i++) crowded.push(commands.spawn([filler])({ [filler]: i })); +}); + +const rank = Symbol("observeCasesTest/rank"); +const second = Symbol("observeCasesTest/second"); + +declare module "@adobe/ecs" { + interface Components { + [rank]: { type: number }; + [second]: { type: number }; + } +} + +Component.register(rank, { type: { type: "number" } }); +Component.register(second, { type: { type: "number" } }); + +const coarse = Symbol("observeCasesTest/coarse"); +declare module "@adobe/ecs" { + interface Components { + [coarse]: { type: number }; + } +} +// Kept at 32 bits, so a 64-bit bound may fall between two values it keeps. +Component.register(coarse, { type: { type: "number", precision: 1 } }); +let coarseValues: readonly number[] = []; +const spawnCoarse = System.create({ name: "observeCasesTest/spawnCoarse" }, ({ commands }) => { + for (const each of coarseValues) commands.spawn([coarse])({ [coarse]: each }); +}); +const shiftCoarse = System.create({ name: "observeCasesTest/shiftCoarse", components: { write: [coarse] } }, row => { row[coarse] = row[coarse] + 0.1; }); + +type Condition = number | { readonly "<"?: number; readonly ">"?: number; readonly "<="?: number; readonly ">="?: number; readonly "=="?: number; readonly "!="?: number }; + +/** Whether a value passes a condition, as `where` tests it. */ +const passesCondition = (value: number, condition: Condition): boolean => typeof condition === "number" ? value === condition : Object.entries(condition).every(([operator, operand]) => + operator === "<" ? value < operand : operator === ">" ? value > operand : operator === "<=" ? value <= operand : operator === ">=" ? value >= operand : operator === "==" ? value === operand : value !== operand); + +/** What `rankAll` spawns: each entity's rank, second key, and whether it's marked, which puts it in another archetype. */ +let ranks: readonly (readonly [number, number, boolean])[] = []; +/** The entities `rankAll` spawned, in order. */ +let ranked: Entity[] = []; +const rankAll = System.create({ name: "observeCasesTest/rankAll" }, ({ commands }) => { + ranked = ranks.map(([value, key, marked]) => commands.spawn(marked ? [rank, second, marker] : [rank, second])({ [rank]: value, [second]: key })); +}); +/** What `rerank` sets: an index into `ranked` and its new rank. */ +let reranks: readonly (readonly [number, number])[] = []; +const rerank = System.create({ name: "observeCasesTest/rerank" }, ({ commands }) => { + for (const [index, value] of reranks) commands.add(ranked[index], rank, value); +}); + +/** A deterministic generator of numbers in [0, 1). */ +const random = (seed: number) => () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +const byIndex = (a: Entity, b: Entity): number => (a % 2 ** 32) - (b % 2 ** 32); + +const { resource, entity, select, count, toPromise } = Observe; + +describe("observing resources", () => { + it("gives the current value, then the value after each run that changed it", async () => { + await using world = createWorld(); + const seen: number[] = []; + resource(world, level)(value => seen.push(value)); + await settle(world); + expect(seen).toEqual([0]); + await world.run(raise, { by: 1 }); + expect(seen).toEqual([0, 2]); + await world.run(touchOther); + expect(seen).toEqual([0, 2]); + await world.run(raise, { by: 3 }); + expect(seen).toEqual([0, 2, 8]); + // A write of the same value stamps it. + await world.run(keep); + expect(seen).toEqual([0, 2, 8, 8]); + }); + + it("notifies a reduced resource only when its fold differs", async () => { + await using world = createWorld(); + const seen: number[] = []; + resource(world, total)(value => seen.push(value)); + await world.run(spawn); + await world.run(sum); + await world.run(sum); + expect(seen).toEqual([0, 2]); + }); + + it("stops an observer that another stops while a run notifies", async () => { + await using world = createWorld(); + const seen: string[] = []; + let stopSecond = (): void => {}; + resource(world, level)(value => { + seen.push(`first ${value}`); + if (value > 0) stopSecond(); + }); + stopSecond = resource(world, level)(value => seen.push(`second ${value}`)); + await settle(world); + await world.run(raise, { by: 1 }); + await world.run(raise, { by: 1 }); + stopSecond(); + expect(seen).toEqual(["first 0", "second 0", "first 2", "first 4"]); + }); + + it("gives an observer added while a run notifies only the value then", async () => { + await using world = createWorld(); + const seen: string[] = []; + resource(world, level)(value => { + if (value === 2) resource(world, level)(later => seen.push(`added ${later}`)); + }); + await settle(world); + await world.run(raise, { by: 1 }); + await settle(world); + expect(seen).toEqual(["added 2"]); + }); + + it("throws to the subscriber when a first value given while subscribing throws", async () => { + await using world = createWorld(); + // The factories give a first value while subscribing once they hold the resource's. + const stop = resource(world, level)(() => {}); + await settle(world); + expect(() => resource(world, level)(() => { throw new Error("first failed"); })).toThrow("first failed"); + const seen: number[] = []; + resource(world, level)(value => seen.push(value)); + await world.run(raise, { by: 1 }); + expect(seen).toEqual([0, 2]); + stop(); + }); + + it("reports an observer that throws while a run notifies, and notifies the others", async () => { + await using world = createWorld(); + const seen: number[] = []; + resource(world, level)(value => { + if (value > 0) throw new Error("observer failed"); + }); + resource(world, level)(value => seen.push(value)); + await settle(world); + const { failures } = await runThenNext(world, raise, { by: 1 }); + expect(failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([ + { kind: "system", name: expect.stringMatching(/^Observe\.resource\(observeCasesTest\/level\)/), message: "observer failed" }, + ]); + expect(seen).toEqual([0, 2]); + }); + + it("give a world disposed after subscribing no first value", async () => { + const world = createWorld(); + const seen: number[] = []; + resource(world, level)(value => seen.push(value)); + await world.dispose(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(seen).toEqual([]); + }); + + it("on a first value that throws when the factories don't hold it, stops that observer and throws it out of band", async () => { + await using world = createWorld(); + let calls = 0; + const failing = (): void => { + calls++; + throw new Error("first failed"); + }; + const caught = await catching(async () => { + resource(world, level)(failing); + await settle(world); + }); + expect(caught.map(error => error instanceof Error ? error.message : error)).toEqual(["first failed"]); + expect((await runReported(world, raise, { by: 1 })).failures).toEqual([]); + expect(calls).toBe(1); + }); +}); + +describe("observing entities", () => { + it("gives the listed components, then the value after each run that wrote a listed component, once", async () => { + await using world = createWorld(); + await world.run(create); + const seen: unknown[] = []; + entity(world, target, { required: [weight], optional: [tag] })(value => seen.push(value)); + await settle(world); + expect(seen).toEqual([{ [weight]: 1 }]); + await world.run(weigh); + expect(seen).toEqual([{ [weight]: 1 }, { [weight]: 3 }]); + await world.run(warm); + expect(seen).toEqual([{ [weight]: 1 }, { [weight]: 3 }]); + }); + + it("notifies when the entity gains or loses an optional component", async () => { + await using world = createWorld(); + await world.run(create); + const seen: unknown[] = []; + entity(world, target, { optional: [flag] })(value => seen.push(value)); + await settle(world); + await world.run(addFlag); + await world.run(warm); + expect(seen).toEqual([{}, { [flag]: true }]); + }); + + it("gives null when the entity loses a required component, and nothing while it stays null", async () => { + await using world = createWorld(); + await world.run(create); + const seen: unknown[] = []; + entity(world, target, { required: [weight], optional: [heat, tag] })(value => seen.push(value)); + await settle(world); + await world.run(addTag); + await world.run(removeWeight); + await world.run(warm); + await world.run(removeTag); + expect(seen).toEqual([{ [weight]: 1, [heat]: 0 }, { [weight]: 1, [heat]: 0, [tag]: true }, null]); + }); + + it("gives null once the entity is despawned, and nothing for another entity that reuses its index", async () => { + await using world = createWorld(); + // Past the runs that spawn reports, whose entities would take the freed index first. + await fillReports(world); + await world.run(create); + const despawned = target; + const seen: unknown[] = []; + entity(world, despawned, { required: [heat] })(value => seen.push(value)); + await settle(world); + await world.run(despawn); + await world.run(create); + expect(Number(target) % 2 ** 32).toBe(Number(despawned) % 2 ** 32); + expect(target).not.toBe(despawned); + await world.run(warm); + expect(seen).toEqual([{ [heat]: 0 }, null]); + const reused: unknown[] = []; + entity(world, target, { required: [heat] })(value => reused.push(value)); + await settle(world); + await world.run(warm); + expect(reused).toEqual([{ [heat]: 1 }, { [heat]: 2 }]); + expect(seen).toEqual([{ [heat]: 0 }, null]); + }); + + it("listing every component, notifies after writes to any of them and adds and removes", async () => { + await using world = createWorld(); + await world.run(create); + const seen: unknown[] = []; + entity(world, target, { optional: [weight, heat, tag] })(value => seen.push(value)); + await settle(world); + await world.run(warm); + await world.run(addTag); + await world.run(weigh); + await world.run(removeWeight); + await world.run(despawn); + await world.run(warm); + expect(seen).toEqual([ + { [weight]: 1, [heat]: 0 }, + { [weight]: 1, [heat]: 1 }, + { [weight]: 1, [heat]: 1, [tag]: true }, + { [weight]: 3, [heat]: 1, [tag]: true }, + { [heat]: 1, [tag]: true }, + null, + ]); + }); + + it("notifies after a kernel writes an observed component whose stamps it had dropped", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(create); + await world.run(double); + const seen: unknown[] = []; + entity(world, target, { required: [heat] })(value => seen.push(value)); + await settle(world); + await world.run(double); + await world.run(weigh); + await world.run(double); + expect(seen).toEqual([{ [heat]: 1 }, { [heat]: 3 }, { [heat]: 7 }]); + }); + + it("shares an entity's entry between observers, stopping one that another stops while a run notifies", async () => { + await using world = createWorld(); + await world.run(create); + const seen: string[] = []; + let stopSecond = (): void => {}; + entity(world, target, { required: [weight] })(value => { + seen.push(`first ${value?.[weight]}`); + if (value !== null && value[weight] > 1) stopSecond(); + }); + stopSecond = entity(world, target, { required: [weight] })(value => seen.push(`second ${value?.[weight]}`)); + await settle(world); + expect((await runReported(world, weigh)).failures).toEqual([]); + expect((await runReported(world, weigh)).failures).toEqual([]); + stopSecond(); + expect(seen).toEqual(["first 1", "second 1", "first 3", "first 5"]); + }); + + it("gives an observer added while a run notifies only the value then", async () => { + await using world = createWorld(); + await world.run(create); + const seen: unknown[] = []; + entity(world, target, { required: [weight] })(value => { + if (value?.[weight] === 3) entity(world, target, { required: [weight] })(later => seen.push(later)); + }); + await settle(world); + await world.run(weigh); + await settle(world); + expect(seen).toEqual([{ [weight]: 3 }]); + }); + + it("reports an observer that throws while a run notifies, and notifies the others", async () => { + await using world = createWorld(); + await world.run(create); + const seen: unknown[] = []; + entity(world, target, { required: [weight] })(value => { + if (value !== null && value[weight] > 1) throw new Error("observer failed"); + }); + entity(world, target, { required: [weight] })(value => seen.push(value)); + await settle(world); + const { failures } = await runThenNext(world, weigh); + expect(failures.map(({ kind, message }) => ({ kind, message }))).toEqual([{ kind: "system", message: "observer failed" }]); + expect(seen).toEqual([{ [weight]: 1 }, { [weight]: 3 }]); + }); + + it("gives an observer subscribed during a run the state after the run", async () => { + await using world = createWorld(); + await world.run(create); + const seen: unknown[] = []; + const subscribe = System.create({ name: "observeCasesTest/subscribeEntity" }, () => { + entity(world, target, { required: [weight] })(value => seen.push(value)); + }); + const later = System.create({ name: "observeCasesTest/laterWeigh", components: { write: [weight] }, after: [subscribe] }, row => { row[weight] += 2; }); + await world.run(System.create({ name: "observeCasesTest/subscribeThenWeigh", systems: [subscribe, later] })); + await settle(world); + await world.run(warm); + expect(seen).toEqual([{ [weight]: 3 }]); + }); +}); + +describe("observing queries", () => { + it("gives the selection, then after each run that changed which entities match", async () => { + await using world = createWorld(); + await world.run(players); + const seen: (readonly Entity[])[] = []; + select(world, { required: [score], not: [benched] })(entities => seen.push(entities)); + await settle(world); + expect(seen).toEqual([[roster[0], roster[1]]]); + await world.run(join); + expect(seen).toEqual([[roster[0], roster[1]], [roster[0], roster[1], roster[3]]]); + await world.run(bench); + expect(seen.map(entities => new Set(entities))).toEqual([new Set([roster[0], roster[1]]), new Set([roster[0], roster[1], roster[3]]), new Set([roster[1], roster[3]])]); + }); + + it("notifies nothing when matching entities are written, or move archetype, but the same ones match", async () => { + await using world = createWorld(); + await world.run(players); + let notified = 0; + select(world, { required: [score], not: [benched] })(() => notified++); + await settle(world); + await world.run(score10); + await world.run(mark); + await world.run(players); + expect(notified).toBe(2); + }); + + it("notifies when as many match as before but other entities", async () => { + await using world = createWorld(); + await world.run(players); + const seen: (readonly Entity[])[] = []; + select(world, { required: [score], not: [benched] })(entities => seen.push(entities)); + await settle(world); + await world.run(swap); + expect(seen.map(entities => new Set(entities))).toEqual([new Set([roster[0], roster[1]]), new Set([roster[1], roster[2]])]); + }); + + it("notifies after a despawn of a matching entity", async () => { + await using world = createWorld(); + await world.run(players); + const seen: number[] = []; + count(world, { required: [score] })(value => seen.push(value)); + await settle(world); + await world.run(leave); + await world.run(score10); + expect(seen).toEqual([3, 2]); + }); + + it("recomputes when the journal dropped entries", async () => { + await using world = createWorld(); + await world.run(players); + await world.run(crowdUp); + const seen: (readonly Entity[])[] = []; + select(world, { required: [score], not: [benched] })(entities => seen.push(entities)); + await settle(world); + await world.run(swapAndCrowd); + expect(seen.map(entities => new Set(entities))).toEqual([new Set([roster[0], roster[1]]), new Set([roster[1], roster[2]])]); + }); + + it("filters by where and notifies when a write changes which entities pass, not when it keeps them", async () => { + await using world = createWorld(); + await world.run(players); + const seen: (readonly Entity[])[] = []; + select(world, { required: [score], where: { [score]: { ">": 1 } } })(entities => seen.push(entities)); + await settle(world); + await world.run(nudge); + await world.run(invert); + await world.run(invert); + expect(seen.map(entities => new Set(entities))).toEqual([new Set([roster[1], roster[2]]), new Set(), new Set([roster[1], roster[2]])]); + }); + + it("notifies after a kernel writes a where component whose stamps it had dropped", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(players); + await world.run(invertKernel); + const seen: number[] = []; + count(world, { required: [score], where: { [score]: { ">": 0 } } })(value => seen.push(value)); + await settle(world); + await world.run(invertKernel); + await world.run(nudge); + await world.run(invertKernel); + expect(seen).toEqual([0, 3, 0]); + }); + + it("sorts by order and notifies when a write changes the order", async () => { + await using world = createWorld(); + await world.run(players); + const seen: (readonly Entity[])[] = []; + select(world, { required: [score], order: { [score]: false } })(entities => seen.push(entities)); + await settle(world); + await world.run(score10); + await world.run(invert); + expect(seen).toEqual([[roster[2], roster[1], roster[0]], [roster[0], roster[1], roster[2]]]); + }); + + it("without required, matches every entity but the resource entity", async () => { + await using world = createWorld({ [level]: 1 }); + await world.run(raise, { by: 1 }); + await world.run(players); + const seen: number[] = []; + count(world, {})(value => seen.push(value)); + const selected: (readonly Entity[])[] = []; + select(world, { not: [benched] })(entities => selected.push(entities)); + await settle(world); + expect(seen).toEqual([3]); + expect(selected).toEqual([[roster[0], roster[1]]]); + }); + + it("counts, notifying only when the number changes", async () => { + await using world = createWorld(); + await world.run(players); + const seen: number[] = []; + count(world, { required: [score], not: [benched] })(value => seen.push(value)); + await settle(world); + await world.run(swap); + await world.run(join); + expect(seen).toEqual([2, 3]); + }); + + it("gives an observer subscribed during a run the state after the run", async () => { + await using world = createWorld(); + await world.run(players); + const seen: number[] = []; + const subscribe = System.create({ name: "observeCasesTest/subscribe" }, () => { + count(world, { required: [score], where: { [score]: { "<": 0 } } })(value => seen.push(value)); + }); + const later = System.create({ name: "observeCasesTest/later", components: { write: [score] }, after: [subscribe] }, row => { row[score] = -row[score]; }); + await world.run(System.create({ name: "observeCasesTest/subscribeThenWrite", systems: [subscribe, later] })); + await settle(world); + expect(seen).toEqual([3]); + }); + + it("reports one that throws during a run, and stops one stopped", async () => { + await using world = createWorld(); + await world.run(players); + count(world, { required: [score, benched] })(value => { + if (value > 1) throw new Error("observer failed"); + }); + const seen: number[] = []; + const stop = count(world, { required: [score, benched] })(value => seen.push(value)); + await settle(world); + const { failures } = await runThenNext(world, bench); + expect(failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([ + { kind: "system", name: expect.stringMatching(/^Observe\.count\(observeCasesTest\/score, observeCasesTest\/benched\)/), message: "observer failed" }, + ]); + stop(); + await world.run(join); + await world.run(leave); + expect(seen).toEqual([1, 2]); + }); + + it("in entity order without order", async () => { + await using world = createWorld(); + ranks = [5, 3, 9, 0, 7, 1].map((value, index) => [value, 0, index % 2 === 0]); + await world.run(rankAll); + const all = await toPromise(select(world, { required: [rank] })); + expect(all).toEqual([...ranked].sort(byIndex)); + }); + + it("breaks ties under order in entity order", async () => { + await using world = createWorld(); + ranks = [1, 1, 0, 0, 1, 1].map((value, index) => [value, 0, index % 2 === 0]); + await world.run(rankAll); + // The marked entities' archetype comes first in storage, but not in what's given. + const zeros = ranked.filter(entity => ranks[ranked.indexOf(entity)][0] === 0); + const ones = ranked.filter(entity => ranks[ranked.indexOf(entity)][0] === 1); + for (const ascending of [true, false]) { + const sorted = await toPromise(select(world, { required: [rank], order: { [rank]: ascending } })); + expect(sorted).toEqual(ascending ? [...zeros, ...ones] : [...ones, ...zeros]); + const paged = await toPromise(select(world, { required: [rank], order: { [rank]: ascending }, page: { offset: 1, limit: 3 } })); + expect(paged).toEqual(sorted.slice(1, 4)); + } + }); +}); + +describe("observing a page of a query", () => { + it("gives the slice at offset of the ordered result, at most limit long", async () => { + await using world = createWorld(); + ranks = [5, 3, 9, 0, 7, 1, 8, 2, 6, 4].map(value => [value, 0, false]); + await world.run(rankAll); + const byRank = [3, 5, 7, 1, 9, 0, 8, 4, 6, 2].map(index => ranked[index]); + const page = (offset: number, limit: number): Promise => toPromise(select(world, { required: [rank], order: { [rank]: true }, page: { offset, limit } })); + expect(await page(0, 3)).toEqual(byRank.slice(0, 3)); + expect(await page(3, 3)).toEqual(byRank.slice(3, 6)); + expect(await page(9, 3)).toEqual(byRank.slice(9)); + expect(await page(12, 3)).toEqual([]); + expect(await page(2, 0)).toEqual([]); + }); + + it("without order, gives the slice of its order without a page, with where applied first", async () => { + await using world = createWorld(); + ranks = [5, 3, 9, 0, 7, 1, 8, 2, 6, 4].map((value, index) => [value, 0, index % 3 === 0]); + await world.run(rankAll); + let all = await toPromise(select(world, { required: [rank], where: { [rank]: { ">": 1 } } })); + let paged = await toPromise(select(world, { required: [rank], where: { [rank]: { ">": 1 } }, page: { offset: 2, limit: 4 } })); + expect(all).toHaveLength(8); + expect(paged).toEqual(all.slice(2, 6)); + all = await toPromise(select(world, { required: [rank] })); + paged = await toPromise(select(world, { required: [rank], page: { offset: 7, limit: 2 } })); + expect(paged).toEqual(all.slice(7, 9)); + }); + + it("notifies when a change shifts the page, not for one outside it", async () => { + await using world = createWorld(); + ranks = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(value => [value, 0, false]); + await world.run(rankAll); + const seen: (readonly Entity[])[] = []; + select(world, { required: [rank], order: { [rank]: true }, page: { offset: 1, limit: 3 } })(entities => seen.push(entities)); + await settle(world); + reranks = [[8, 7.5], [9, 20]]; + await world.run(rerank); + reranks = [[0, 2.5]]; + await world.run(rerank); + reranks = [[7, 1.5]]; + await world.run(rerank); + reranks = [[5, -1]]; + await world.run(rerank); + expect(seen).toEqual([ + [ranked[1], ranked[2], ranked[3]], + [ranked[2], ranked[0], ranked[3]], + [ranked[7], ranked[2], ranked[0]], + [ranked[1], ranked[7], ranked[2]], + ]); + }); + + it("selects the same entities as sorting everything and slicing, ties included", async () => { + await using world = createWorld(); + const next = random(7); + ranks = Array.from({ length: 300 }, () => [Math.floor(next() * 6), Math.floor(next() * 3), next() < 0.4]); + await world.run(rankAll); + const order = { [rank]: true, [second]: false }; + let full: readonly Entity[] = []; + select(world, { required: [rank, second], order })(entities => { full = entities; }); + const pages = [[0, 10], [0, 1], [7, 25], [123, 60], [290, 40], [0, 300], [0, 1000], [350, 5], [40, 0]]; + const latest = pages.map((): readonly Entity[] => []); + pages.forEach(([offset, limit], index) => { + select(world, { required: [rank, second], order, page: { offset, limit } })(entities => { latest[index] = entities; }); + }); + await settle(world); + const check = (): void => pages.forEach(([offset, limit], index) => expect(latest[index]).toEqual(full.slice(offset, offset + limit))); + check(); + /** Each entity's rank now, by its position in `ranked`. */ + const current = ranks.map(([value]) => value); + for (let round = 0; round < 5; round++) { + reranks = Array.from({ length: 40 }, () => [Math.floor(next() * ranked.length), Math.floor(next() * 6)]); + for (const [index, value] of reranks) current[index] = value; + await world.run(rerank); + check(); + } + for (const ascending of [false, true]) { + const one = { [rank]: ascending }; + full = await toPromise(select(world, { required: [rank], order: one })); + expect(await toPromise(select(world, { required: [rank], order: one, page: { offset: 13, limit: 77 } }))).toEqual(full.slice(13, 90)); + const filtered = await toPromise(select(world, { required: [rank], where: { [rank]: { "<": 4 } }, order: one })); + expect(filtered).toEqual(full.filter(entity => current[ranked.indexOf(entity)] < 4)); + expect(await toPromise(select(world, { required: [rank], where: { [rank]: { "<": 4 } }, order: one, page: { offset: 5, limit: 20 } }))).toEqual(filtered.slice(5, 25)); + } + }); + + it("count and select by where at the bounds of a range, at a component's precision, as each value tests", async () => { + await using world = createWorld(); + coarseValues = [0, 0.1, 0.1, 0.2, 0.30000001, 1, 1, 2, 3, -1, 0.1000000001]; + await world.run(spawnCoarse); + const conditions: readonly Condition[] = [0.1, 1, { "<": 0.1 }, { "<=": 0.1 }, { ">": 0.1 }, { ">=": 0.2, "<": 1 }, { ">": 0.1, "<=": 0.1 }, { "<": 1.0000000001 }, + { ">": 0.99999999999 }, { "!=": 1 }, { ">": 0, "!=": 1 }, { "==": 0.1 }, { ">=": 5 }, { "<": -2 }]; + const counts = conditions.map(() => -1); + const selections = conditions.map((): readonly Entity[] => []); + conditions.forEach((condition, k) => { + count(world, { required: [coarse], where: { [coarse]: condition } })(value => { counts[k] = value; }); + select(world, { required: [coarse], where: { [coarse]: condition } })(value => { selections[k] = value; }); + }); + await settle(world); + for (let round = 0; round < 3; round++) { + // What each condition passes of every value, read one by one. + const all = await toPromise(select(world, { required: [coarse] })); + const values = await Promise.all(all.map(async each => (await toPromise(entity(world, each, { required: [coarse] })))?.[coarse] ?? Number.NaN)); + conditions.forEach((condition, k) => { + const passing = all.filter((_, i) => passesCondition(values[i], condition)); + expect([k, counts[k]]).toEqual([k, passing.length]); + expect([k, new Set(selections[k])]).toEqual([k, new Set(passing)]); + }); + await world.run(shiftCoarse); + } + }); + + it("leaves count the total", async () => { + await using world = createWorld(); + ranks = [0, 1, 2, 3, 4].map(value => [value, 0, false]); + await world.run(rankAll); + // Not a literal, so its page passes the type: count ignores it. + const paged = { required: [rank], page: { offset: 1, limit: 2 } } as const; + expect(await toPromise(count(world, paged))).toBe(5); + }); + + it("throws when subscribing with an offset or limit that isn't a non-negative integer", async () => { + await using world = createWorld(); + for (const page of [{ offset: -1, limit: 1 }, { offset: 1.5, limit: 1 }, { offset: 0, limit: -1 }, { offset: 0, limit: Number.NaN }]) { + expect(() => select(world, { required: [rank], page })(() => {})).toThrow("page"); + } + }); +}); + +describe("the factories' own runs", () => { + it("rethrow their systems' failures out of band, from their reports, once a later run of theirs read them", async () => { + await using world = createWorld({ [workers]: 0 }); + const fail = System.create({ name: "observeCasesTest/failRun" }, () => { throw new Error("run failed"); }); + const nothing = System.create({ name: "observeCasesTest/nothing" }, () => {}); + let reports = -1; + const caught = await catching(async () => { + // Its notifier throws once the failed run's report loses its errors, which a run of the factories' own does + // as it takes that report's entity. + count(world, { required: [Report.errors] })(value => { + if (reports === 1 && value === 0) throw new Error("count failed"); + reports = value; + }); + await settle(world); + const failed = await world.run(fail); + expect(reports).toBe(1); + while (await world.run(nothing) < failed + retainedRuns - 1); + // The factories' runs for these first values: the first takes the report, failing in its pass. + resource(world, level)(() => {}); + await settle(world); + resource(world, other)(() => {}); + await settle(world); + }); + expect(caught.map(error => error instanceof Error ? error.message : error)).toEqual([expect.stringMatching(/^Observe\.count\(Report\/errors\).*: count failed$/)]); + }); +}); diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts new file mode 100644 index 000000000..307b4eacc --- /dev/null +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { Observe } from "../../observe/index.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { tablesOf } from "./join.js"; +import { settle } from "./observe.test-helper.js"; +import { runReported } from "./reports.test-helper.js"; + +// How the internal factories of `src/observe` run: systems shared by shape, first values batched or given at once, warm +// reads, and what they hold released once idle. + +const amount = Symbol("observeFactoriesTest/amount"); +const other = Symbol("observeFactoriesTest/other"); +const level = Symbol("observeFactoriesTest/level"); +declare module "@adobe/ecs" { + interface Components { + [amount]: { type: number }; + [other]: { type: number }; + } + interface Resources { + [level]: { type: number }; + } +} +Component.register(amount, { type: { type: "number" } }); +Component.register(other, { type: { type: "number" } }); +Resource.register(level, { type: { type: "number", default: 0 } }); + +let spawned: Entity[] = []; +const seed = System.create({ name: "observeFactoriesTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + spawned = []; + for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); +}); +const grow = System.create({ name: "observeFactoriesTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); +const raise = System.create({ name: "observeFactoriesTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const nothing = System.create({ name: "observeFactoriesTest/nothing" }, () => {}); + +/** Counts passes: the observer passes after each run's sync point. */ +const passes = { count: 0 }; +const counter = System.create({ name: "observeFactoriesTest/counter", observer: true }, () => { passes.count++; }); + +/** + * A world of `count` entities, each amount its index and one more, at level 1: the runs below registered, so runners + * count only what observers hold. + */ +const seeded = async (count: number): Promise => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count }); + await world.run(nothing); + await world.run(raise); + await world.run(grow); + await settle(world); + return world; +}; + +const runnersOf = async (world: CreatedWorld): Promise => (await world.tables()).runners; + +describe("observer factories", () => { + it("share one observer system among every observer of one shape, whatever their entities, where values and pages", async () => { + await using world = await seeded(100); + const stops = [Observe.entity(world, spawned[0], { required: [amount] })(() => {}), Observe.select(world, { required: [amount], where: { [amount]: 0 }, page: { offset: 0, limit: 5 } })(() => {})]; + await settle(world); + const before = await runnersOf(world); + for (let i = 1; i < 100; i++) stops.push(Observe.entity(world, spawned[i], { required: [amount] })(() => {})); + for (let i = 1; i < 10; i++) stops.push(Observe.select(world, { required: [amount], where: { [amount]: { ">": i } }, page: { offset: i, limit: 5 } })(() => {})); + await settle(world); + expect(await runnersOf(world)).toBe(before); + for (const stop of stops) stop(); + }); + + it("give a value they hold while subscribing, and batch the rest into the runs of one flush", async () => { + await using world = await seeded(10); + world.add(counter); + await settle(world); + passes.count = 0; + const values: number[] = []; + for (let i = 0; i < 10; i++) Observe.entity(world, spawned[i], { required: [amount] })(value => values.push(value?.[amount] ?? -1)); + Observe.select(world, { required: [amount], where: { [amount]: { "<": 5 } } })(() => {}); + Observe.select(world, { required: [amount], where: { [amount]: { "<": 7 } } })(() => {}); + expect(values).toEqual([]); + await settle(world); + // A scan for both selections, the delivering run, and the settling run. + expect(passes.count).toBe(3); + expect(values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const warm: number[] = []; + Observe.entity(world, spawned[3], { required: [amount] })(value => warm.push(value?.[amount] ?? -1))(); + expect(warm).toEqual([4]); + }); + + it("stay warm for reads of first values in a loop, then release what they hold once found idle in three passes", async () => { + await using world = await seeded(10); + await Observe.toPromise(Observe.resource(world, level)); + await Observe.toPromise(Observe.entity(world, spawned[0], { required: [amount] })); + const held = await runnersOf(world); + for (let i = 0; i < 3; i++) { + const values: number[] = []; + Observe.resource(world, level)(value => values.push(value))(); + Observe.entity(world, spawned[0], { required: [amount] })(value => values.push(value?.[amount] ?? -1))(); + expect(values).toEqual([1 + i, 1 + i]); + await world.run(raise); + await world.run(grow); + } + expect(await runnersOf(world)).toBe(held); + // The resource's and the entity's systems. + await world.run(nothing); + expect(await runnersOf(world)).toBe(held - 2); + // Used again, they're added again, and cold. + const values: number[] = []; + Observe.resource(world, level)(value => values.push(value))(); + expect(values).toEqual([]); + expect(await Observe.toPromise(Observe.resource(world, level))).toBe(4); + expect(await runnersOf(world)).toBe(held - 1); + // An entity observer's system, added again, reads through its new lookups. + const entities: unknown[] = []; + const stop = Observe.entity(world, spawned[0], { required: [amount] })(value => entities.push(value?.[amount])); + await settle(world); + await world.run(grow); + await world.run(System.create({ name: "observeFactoriesTest/growOthers", components: { write: [amount] } }, row => { + if (row.entity !== spawned[0]) row[amount] += 1; + })); + expect(entities).toEqual([4, 5]); + stop(); + }); + + it("track a query only once a subscriber outlives its first value", async () => { + await using world = await seeded(10); + expect(await Observe.toPromise(Observe.count(world, { required: [amount], where: { [amount]: { ">=": 5 } } }))).toBe(6); + const scanned = await runnersOf(world); + await world.run(nothing); + expect(await runnersOf(world)).toBe(scanned); + const counts: number[] = []; + const stop = Observe.count(world, { required: [amount], where: { [amount]: { ">=": 5 } } })(value => counts.push(value)); + await settle(world); + expect(await runnersOf(world)).toBe(scanned + 1); + await world.run(grow); + expect(counts).toEqual([6, 7]); + stop(); + await world.run(nothing); + await world.run(nothing); + await world.run(nothing); + expect(await runnersOf(world)).toBe(scanned); + }); + + it("notify subscribers whose first value was one held, while subscribing, of every later change", async () => { + await using world = await seeded(3); + const held = [Observe.resource(world, level)(() => {}), Observe.entity(world, spawned[0], { required: [amount] })(() => {}), Observe.count(world, { required: [amount], where: { [amount]: { ">": 1 } } })(() => {})]; + await settle(world); + const levels: number[] = []; + const amounts: number[] = []; + const counts: number[] = []; + const stops = [ + Observe.resource(world, level)(value => levels.push(value)), + Observe.entity(world, spawned[0], { required: [amount] })(value => amounts.push(value?.[amount] ?? -1)), + Observe.count(world, { required: [amount], where: { [amount]: { ">": 1 } } })(value => counts.push(value)), + ]; + await world.run(raise); + await world.run(grow); + await world.run(grow); + expect([levels, amounts, counts]).toEqual([[1, 2], [1, 2, 3], [2, 3]]); + for (const stop of [...held, ...stops]) stop(); + }); + + it("keep the values of observers of another world apart", async () => { + await using first = await seeded(3); + await using second = await seeded(5); + expect(await Promise.all([Observe.toPromise(Observe.count(first, {})), Observe.toPromise(Observe.count(second, {}))])).toEqual([3, 5]); + await first.run(raise); + expect(await Promise.all([Observe.toPromise(Observe.resource(first, level)), Observe.toPromise(Observe.resource(second, level))])).toEqual([2, 1]); + }); + + it("read only what they list, so a write elsewhere notifies nothing", async () => { + await using world = await seeded(3); + const writeOther = System.create({ name: "observeFactoriesTest/writeOther", components: { write: [other] } }, row => { row[other] = 1; }); + let notified = 0; + Observe.entity(world, spawned[0], { required: [amount] })(() => notified++); + Observe.select(world, { required: [amount], order: { [amount]: false }, page: { offset: 0, limit: 2 } })(() => notified++); + Observe.count(world, { required: [amount] })(() => notified++); + await settle(world); + await world.run(writeOther); + await world.run(nothing); + expect(notified).toBe(3); + }); + + it("rethrow only their own systems' failures from their runs, not those of observer systems the world added", async () => { + await using world = await seeded(3); + const failing = System.create({ name: "observeFactoriesTest/failing", observer: true }, () => { throw new Error("added failed"); }); + world.add(failing); + const caught: unknown[] = []; + const original = globalThis.queueMicrotask; + vi.spyOn(globalThis, "queueMicrotask").mockImplementation(callback => original(() => { + try { + callback(); + } + catch (error) { + caught.push(error); + } + })); + try { + // Cold first values, from runs of the factories' own, each running the added system too. + expect(await Observe.toPromise(Observe.resource(world, level))).toBe(1); + expect(await Observe.toPromise(Observe.count(world, { required: [amount] }))).toBe(3); + await new Promise(resolve => setTimeout(resolve, 10)); + } + finally { + vi.restoreAllMocks(); + } + expect(caught).toEqual([]); + // The world's runs still report it. + expect((await runReported(world, nothing)).failures.map(failure => failure.name)).toEqual(["observeFactoriesTest/failing"]); + }); +}); + +/** A world of `count` entities as `seeded` makes them, and a thread joined to it through a channel. */ +const seededJoined = async (count: number): Promise<{ readonly world: CreatedWorld; readonly remote: World; readonly close: () => Promise }> => { + const world = await seeded(count); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: [] }); + await remote.run(nothing); + return { + world, remote, + close: async () => { + await remote.dispose(); + await world.dispose(); + }, + }; +}; + +/** How many observer systems are placed on a joined thread. */ +const placedOn = (remote: World): number => tablesOf.get(remote)?.().observer ?? -1; + +/** A few turns of the event loop, which a joined thread's posts need. */ +const turns = async (count = 20): Promise => { + for (let turn = 0; turn < count; turn++) await new Promise(resolve => setTimeout(resolve, 0)); +}; + +describe("observer factories on a joined thread", () => { + it("share one observer system there among every observer of one shape, and notify each of every later change", async () => { + const { world, remote, close } = await seededJoined(20); + const amounts: number[] = []; + const stops = [ + Observe.entity(remote, spawned[0], { required: [amount] })(value => amounts.push(value?.[amount] ?? -1)), + Observe.select(remote, { required: [amount], where: { [amount]: { ">": 10 } }, order: { [amount]: false }, page: { offset: 0, limit: 3 } })(() => {}), + Observe.resource(remote, level)(() => {}), + ]; + await Observe.toPromise(Observe.count(remote, { required: [amount] })); + const placed = placedOn(remote); + for (let i = 1; i < 20; i++) stops.push(Observe.entity(remote, spawned[i], { required: [amount] })(() => {})); + for (let i = 1; i < 5; i++) stops.push(Observe.select(remote, { required: [amount], where: { [amount]: { ">": i } }, order: { [amount]: false }, page: { offset: i, limit: 3 } })(() => {})); + const levels: number[] = []; + stops.push(Observe.resource(remote, level)(value => levels.push(value))); + expect(await Observe.toPromise(Observe.select(remote, { required: [amount], where: { [amount]: { ">": 1 } }, order: { [amount]: false }, page: { offset: 1, limit: 3 } }))).toEqual([spawned[18], spawned[17], spawned[16]]); + expect(placedOn(remote)).toBe(placed); + // Held, so given while subscribing. + expect(levels).toEqual([1]); + await world.run(raise); + await world.run(grow); + await turns(); + expect([amounts, levels]).toEqual([[1, 2], [1, 2]]); + for (const stop of stops) stop(); + await close(); + }); + + it("give cold first values from a run after the thread's runs called before subscribing", async () => { + const { remote, close } = await seededJoined(3); + void remote.run(seed, { count: 5 }); + const count = Observe.toPromise(Observe.count(remote, { required: [amount] })); + void remote.run(raise); + const resource = Observe.toPromise(Observe.resource(remote, level)); + expect(await Promise.all([count, resource])).toEqual([8, 2]); + await close(); + }); + + it("release what they place there once found idle in three turns that ran them", async () => { + const { world, remote, close } = await seededJoined(3); + const before = placedOn(remote); + const stop = Observe.entity(remote, spawned[0], { required: [amount] })(() => {}); + expect(await Observe.toPromise(Observe.resource(remote, level))).toBe(1); + expect(placedOn(remote)).toBe(before + 2); + stop(); + for (let k = 0; k < 4; k++) { + await world.run(raise); + await turns(5); + } + expect(placedOn(remote)).toBe(before); + // Used again, added again. + expect(await Observe.toPromise(Observe.resource(remote, level))).toBe(5); + await close(); + }); +}); + +describe("Observe.toPromise", () => { + it("resolves to the first value and stops observing, even when it comes while subscribing", async () => { + let stopped = 0; + const observe: Observe = notify => { notify(1); notify(2); return () => { stopped++; }; }; + expect(await Observe.toPromise(observe)).toBe(1); + expect(stopped).toBe(1); + }); +}); diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts new file mode 100644 index 000000000..0b4484cfd --- /dev/null +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { Observe } from "../../observe/index.js"; +import { reportOf } from "./reports.test-helper.js"; + +// The observer factories of `src/observe` on a joined thread's world, with the world and the thread on this one, +// joined through a MessageChannel. Each case waits for the value it expects, so it holds whenever values come. + +const point = Symbol("observeJoinedTest/point"); +const label = Symbol("observeJoinedTest/label"); +const marked = Symbol("observeJoinedTest/marked"); +const total = Symbol("observeJoinedTest/total"); +const names = Symbol("observeJoinedTest/names"); +const place = Symbol("observeJoinedTest/place"); +const caption = Symbol("observeJoinedTest/caption"); +const widget = Symbol("observeJoinedTest/widget"); +const kept = Symbol("observeJoinedTest/kept"); +declare module "@adobe/ecs" { + export interface Threads { + observeJoinedTest: true; + } + export interface Components { + [point]: { type: readonly [number, number] }; + [label]: { type: string }; + [marked]: { type: true }; + [place]: { type: number }; + [caption]: { type: { readonly text: string } | null; thread: "observeJoinedTest" }; + [widget]: { type: { readonly draw: () => string } | null; thread: "observeJoinedTest" }; + [kept]: { type: string }; + } + export interface Resources { + [total]: { type: number }; + [names]: { type: readonly string[] }; + } +} +Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(label, { type: { type: "string" } }); +Component.register(marked, { type: { const: true } }); +Component.register(place, { type: { type: "number" } }); +/** The caption's start, which a command here gives it by this value. */ +const none = { text: "none" }; +Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "observeJoinedTest" }); +Component.register(widget, { thread: "observeJoinedTest" }); +Component.register(kept, { type: { type: "string" } }); +Resource.register(total, { type: { type: "number", default: 0 } }); +Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); + +const add = System.create({ name: "observeJoinedTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { + resources[total] = resources[total] * 10 + input.amount; +}); +const spawn = System.create({ name: "observeJoinedTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { + commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); +}); +const spawnPlaced = System.create({ name: "observeJoinedTest/spawnPlaced", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { + commands.spawn([place, caption, widget, kept])({ [place]: input.x, [widget]: null, [kept]: `k${input.x}` }); +}); +const nullCaptions = System.create({ name: "observeJoinedTest/nullCaptions", components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); +const restartCaptions = System.create({ name: "observeJoinedTest/restartCaptions", components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); +const dropCaptions = System.create({ name: "observeJoinedTest/dropCaptions", components: { has: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); + +/** A world shared with a thread joined through a channel claiming `threads`, and the joined world. */ +const joined = (threads: readonly "observeJoinedTest"[] = []): { readonly world: CreatedWorld; readonly remote: World } => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads }); + return { world, remote }; +}; + +/** The next value an observer gives that `accept` takes. */ +const until = (observe: (notify: (value: T) => void) => () => void, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let stop: (() => void) | null = null; + let done = false; + stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop?.()); + resolve(value); + }); + if (done) stop(); +}); + +/** Errors thrown out of band while `run` runs, as uncaught errors would be, caught instead. */ +const catching = async (run: () => Promise): Promise => { + const caught: unknown[] = []; + const original = globalThis.queueMicrotask; + vi.spyOn(globalThis, "queueMicrotask").mockImplementation(callback => original(() => { + try { + callback(); + } + catch (error) { + caught.push(error); + } + })); + try { + await run(); + } + finally { + vi.restoreAllMocks(); + } + return caught; +}; + +/** Once the thread shared last is joined, its claims made. */ +const joinedStatus = (world: CreatedWorld) => until(world.threads, threads => threads.at(-1)?.status === "joined"); + +/** The entity a run of `spawnPlaced` spawned. */ +const spawnOne = async (world: World, x: number): Promise => { + await world.run(spawnPlaced, { x }); + const [entity] = await until(Observe.select(world, { required: [place], where: { [place]: x } }), entities => entities.length === 1); + return entity; +}; + +describe("observer factories on a joined thread", () => { + it("read the strings the world's thread writes, which live in shared memory", async () => { + const { world, remote } = joined(); + await world.run(spawn, { x: 3 }); + const [entity] = await until(Observe.select(remote, { required: [point], where: { [point]: [3, -3] } }), entities => entities.length === 1); + expect(await until(Observe.entity(remote, entity, { required: [label] }), value => value !== null)).toEqual({ [label]: "p3" }); + await remote.dispose(); + await world.dispose(); + }); + it("give values of resources, entities, select and count, then each newer one", async () => { + const { world, remote } = joined(); + const first: unknown[] = []; + const stop = Observe.resource(remote, total)(value => first.push(value)); + // Never during the subscribing call: the thread holds nothing yet. + expect(first).toEqual([]); + expect(await until(Observe.resource(remote, total), value => value === 0)).toBe(0); + stop(); + await world.run(add, { amount: 7 }); + expect(await until(Observe.resource(remote, total), value => value === 7)).toBe(7); + await remote.run(spawn, { x: 1 }); + await remote.run(spawn, { x: 2 }); + const selected = await until(Observe.select(remote, { required: [point], order: { [point]: false } }), entities => entities.length === 2); + expect(await until(Observe.count(remote, { required: [point], where: { [point]: [2, -2] } }), count => count === 1)).toBe(1); + const listed = await until(Observe.entity(remote, selected[0], { required: [point], optional: [marked] }), value => value !== null); + expect(listed).toEqual({ [point]: [2, -2], [marked]: true }); + await remote.dispose(); + await world.dispose(); + }); + + it("stop notifying once stopped", async () => { + const { world, remote } = joined(); + const seen: number[] = []; + const stop = Observe.resource(remote, total)(value => seen.push(value)); + await until(Observe.resource(remote, total), value => value === 0); + stop(); + await world.run(add, { amount: 1 }); + await until(Observe.resource(remote, total), value => value === 1); + expect(seen).toEqual([0]); + await remote.dispose(); + await world.dispose(); + }); + + it("give one that fell behind the newest value", async () => { + const { world, remote } = joined(); + const seen: number[] = []; + Observe.resource(remote, total)(value => seen.push(value)); + await until(Observe.resource(remote, total), value => value === 0); + for (let k = 1; k <= 5; k++) await world.run(add, { amount: k }); + await until(Observe.resource(remote, total), value => value === 12345); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(seen.at(-1)).toBe(12345); + expect(seen.length).toBeLessThanOrEqual(6); + await remote.dispose(); + await world.dispose(); + }); + + it("throw for one that reads objects of the world's thread, or looks up by its keys of strings", async () => { + const { world, remote } = joined(); + const ignore = (): void => {}; + // The factories make nothing until subscribed, so they throw when subscribed. + const made = (make: () => (notify: (value: T) => void) => () => void) => () => make()(ignore); + expect(made(() => Observe.resource(remote, names))).toThrow('reads "observeJoinedTest/names", an object, which lives on another thread'); + expect(made(() => Observe.select(remote, { required: [label], where: { [label]: "a" } }))).toThrow(/"observeJoinedTest\/label"[^.]*a string, which the world's thread keeps keys of/); + // What threw holds nothing: observers it could make still work. + expect(await until(Observe.resource(remote, total), value => value === 0)).toBe(0); + await remote.dispose(); + await world.dispose(); + }); + + it("read the objects of the threads the thread fills, at their starts or null as the world's commands give them", async () => { + const { world, remote } = joined(["observeJoinedTest"]); + await joinedStatus(world); + const entity = await spawnOne(world, 1); + const listed = Observe.entity(remote, entity, { required: [place, caption], optional: [widget] }); + expect(await until(listed, value => value !== null)).toEqual({ [place]: 1, [caption]: none, [widget]: null }); + await world.run(nullCaptions); + expect(await until(listed, value => value?.[caption] === null)).toEqual({ [place]: 1, [caption]: null, [widget]: null }); + await world.run(restartCaptions); + expect(await until(listed, value => value?.[caption]?.text === "none")).toEqual({ [place]: 1, [caption]: none, [widget]: null }); + await world.run(dropCaptions); + expect(await until(listed, value => value === null)).toBeNull(); + expect(await until(Observe.entity(remote, entity, { required: [place], optional: [caption] }), value => value !== null)).toEqual({ [place]: 1 }); + await remote.dispose(); + await world.dispose(); + }); + + it("fail on the world's thread for one that reads objects a joined thread fills", async () => { + const { world, remote } = joined(["observeJoinedTest"]); + await joinedStatus(world); + const entity = await spawnOne(world, 3); + const values: unknown[] = []; + const observe = (): void => { + Observe.entity(world, entity, { required: [caption] })(value => values.push(value)); + Observe.select(world, { required: [place, caption], where: { [caption]: none } })(() => {}); + }; + const reported: string[] = []; + const caught = await catching(async () => { + observe(); + const runs: number[] = []; + for (let k = 4; k < 6; k++) runs.push(await world.run(spawnPlaced, { x: k })); + await new Promise(resolve => setTimeout(resolve, 10)); + for (const run of runs) for (const failure of (await reportOf(world, run)).failures) reported.push(failure.message); + }); + // The entity observer's system, added, fails in a run's report; the selection's scan, run, rejects, rethrown. + expect([...reported, ...caught.map(String)]).toEqual([ + expect.stringMatching(/^System "Observe\.entity\(observeJoinedTest\/caption\).*runs on thread "observeJoinedTest", which a joined thread fills, and has no id/), + expect.stringMatching(/System "Observe\.select\(observeJoinedTest\/place, observeJoinedTest\/caption\).*runs on thread "observeJoinedTest", which a joined thread fills, and has no id/), + ]); + expect(values).toEqual([]); + await remote.dispose(); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts new file mode 100644 index 000000000..0631125ef --- /dev/null +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { Component, Resource, System, always, cadence, clock, wake, workers } from "../../index.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld } from "./create.js"; +import { Observe } from "../../observe/index.js"; +import { followReports, reportOf, runReported } from "./reports.test-helper.js"; + +// Ticks and tick stamps, read through the observer factories of `src/observe`. + +const frames = Symbol("observeTicksCasesTest/frames"); +const seen = Symbol("observeTicksCasesTest/seen"); +const value = Symbol("observeTicksCasesTest/value"); +const watched = Symbol("observeTicksCasesTest/watched"); +const flag = Symbol("observeTicksCasesTest/flag"); +declare module "@adobe/ecs" { + interface Resources { + [frames]: { type: number }; + [seen]: { type: number }; + } + interface Components { + [value]: { type: number }; + [watched]: { type: number }; + [flag]: { type: true }; + } +} +Resource.register(frames, { type: { type: "number", default: 0 } }); +Resource.register(seen, { type: { type: "number", default: -1 } }); +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(watched, { type: { type: "number", precision: 1 } }); +Component.register(flag, { type: { const: true } }); + +const count = System.create({ name: "observeTicksCasesTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); +const watch = System.create({ name: "observeTicksCasesTest/watch", resources: { read: [clock], write: [seen] } }, ({ resources }) => { resources[seen] = resources[clock].tick; }); +const fail = System.create({ name: "observeTicksCasesTest/fail" }, () => { throw new Error("tick failed"); }); +const nothing = System.create({ name: "observeTicksCasesTest/nothing" }, () => {}); +const idle = System.create({ name: "observeTicksCasesTest/idle", observer: true }, () => {}); +const fast = System.create({ name: "observeTicksCasesTest/fast", resources: { write: [cadence, always] } }, ({ resources }) => { + resources[cadence] = { kind: "fixed", step: 1 }; + resources[always] = true; +}); +const calm = System.create({ name: "observeTicksCasesTest/calm", resources: { write: [always] } }, ({ resources }) => { resources[always] = false; }); +const later = System.create({ name: "observeTicksCasesTest/later", resources: { read: [clock] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { + if (resources[clock].tick === 0) reduce[wake](resources[clock].now + 20); +}); + +const seed = System.create({ name: "observeTicksCasesTest/seed" }, ({ commands }) => { + for (let i = 0; i < 3000; i++) commands.spawn([value, watched])({ [value]: i, [watched]: 0 }); +}); +/** Writes every value: until something observes `value`, its stamps are muted. */ +const bump = System.create({ name: "observeTicksCasesTest/bump", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); +const watchChanged = System.create({ name: "observeTicksCasesTest/watchChanged", kernel: "cpu", components: { write: [watched], changed: [value] }, after: [bump] }, row => { + row[watched] = row[watched] + 1; +}); +/** Counts rows whose `value` was added since it last ran. */ +const fresh = System.create({ name: "observeTicksCasesTest/fresh", components: { write: [watched], added: [value] } }, row => { + row[watched] = row[watched] + 1; +}); +const flagAll = System.create({ name: "observeTicksCasesTest/flagAll", components: { has: [value] } }, (row, { commands }) => { + commands.add(row.entity, flag, true); +}); +const spawnOne = System.create({ name: "observeTicksCasesTest/spawnOne" }, ({ commands }) => { + commands.spawn([value, watched])({ [value]: -1, [watched]: 0 }); +}); + +const pause = (milliseconds: number): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)); + +const { resource, entity, select, toPromise } = Observe; + +/** The resource's value once `done` holds of it. */ +const until = (observe: (notify: (value: T) => void) => () => void, done: (value: T) => boolean): Promise => new Promise(resolve => { + let stop: (() => void) | null = null; + let finished = false; + stop = observe(next => { + if (finished || !done(next)) return; + finished = true; + resolve(next); + stop?.(); + }); + if (finished) stop(); +}); + +/** Every entity's `watched`, through the observers. */ +const watchedOf = async (world: World): Promise => { + const entities = await toPromise(select(world, { required: [watched] })); + return Promise.all(entities.map(async each => (await toPromise(entity(world, each, { required: [watched] })))?.[watched] ?? -1)); +}; + +describe("ticks", () => { + it("run the added systems on the beat after a run arrives, with the clock, and only while one is added", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(nothing); + await pause(40); + expect(await toPromise(resource(world, frames))).toBe(0); + world.add(count); + world.add(watch); + await pause(40); + // A quiet loop runs no tick until a run arrives, and reading doesn't make it. + expect(await toPromise(resource(world, frames))).toBe(0); + await world.run(nothing); + expect(await until(resource(world, frames), next => next === 1)).toBe(1); + expect(await toPromise(resource(world, seen))).toBe(0); + const { tick, delta } = await toPromise(resource(world, clock)); + expect([tick, delta]).toEqual([0, 0]); + await pause(40); + expect(await toPromise(resource(world, frames))).toBe(1); + }); + + it("keep ticking while always is set, at the cadence's step, and stop when it's cleared or the world is disposed", async () => { + const world = createWorld({ [workers]: 0 }); + world.add(count); + await world.run(fast); + expect(await until(resource(world, frames), next => next >= 5)).toBeGreaterThanOrEqual(5); + expect((await toPromise(resource(world, clock))).delta).toBe(1); + await world.run(calm); + await pause(10); + const stopped = await toPromise(resource(world, frames)); + await pause(20); + expect(await toPromise(resource(world, frames))).toBe(stopped); + const values: number[] = []; + const stop = resource(world, frames)(next => values.push(next)); + await world.run(fast); + await until(resource(world, frames), next => next > stopped); + await world.dispose(); + const disposed = values.length; + await pause(20); + expect(values.length).toBe(disposed); + stop(); + }); + + it("tick again at the wake time a system asks for", async () => { + await using world = createWorld({ [workers]: 0 }); + world.add(later); + world.add(count); + await world.run(nothing); + expect(await until(resource(world, frames), next => next === 2)).toBe(2); + await pause(40); + expect(await toPromise(resource(world, frames))).toBe(2); + }); + + it("spawn a failing tick's report", async () => { + await using world = createWorld({ [workers]: 0 }); + const reports = followReports(world); + world.add(fail); + await world.run(nothing); + const last = await reports.until(next => next.failures.length > 0); + expect(last.failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "observeTicksCasesTest/fail", "tick failed"]]); + }); + + it("put what an observer throws on a tick's values in a later run's report, once", async () => { + await using world = createWorld({ [workers]: 0 }); + const reports = followReports(world); + world.add(count); + let counts = 0; + const stopFrames = resource(world, frames)(() => { + if (++counts === 2) throw new Error("frames observer failed"); + }); + await world.run(nothing); + await until(resource(world, frames), next => next === 1); + // A run of observer systems alone, which ticks nothing, so its report holds what the tick's pass threw. + await world.run(idle); + const thrown = await reports.until(report => report.failures.length > 0); + expect(thrown.failures.map(({ kind, message }) => [kind, message])).toEqual([["system", "frames observer failed"]]); + await world.run(idle); + expect(reports.seen.filter(report => report.failures.length > 0)).toHaveLength(1); + stopFrames(); + }); + + it("start the loop's resources at their defaults, workers at null for the platform's choice", async () => { + await using world = createWorld(); + expect(await toPromise(resource(world, cadence))).toEqual({ kind: "frame" }); + expect(await toPromise(resource(world, always))).toBe(false); + expect(await toPromise(resource(world, wake))).toBe(Infinity); + expect(await toPromise(resource(world, workers))).toBe(null); + }); +}); + +describe("tick stamps only where observed", () => { + it("keep `added` from the moment an observer registers, across archetype moves", async () => { + const world = createWorld(); + await world.run(seed); + // Rows that existed before count as added once. + await world.run(fresh); + await world.run(fresh); + expect(new Set(await watchedOf(world))).toEqual(new Set([1])); + // Moving archetype keeps a row's `added` tick: old rows aren't added again, and a new row still is. + await world.run(spawnOne); + await world.run(flagAll); + await world.run(fresh); + await world.run(fresh); + expect((await watchedOf(world)).filter(each => each === 1).length).toBe(3001); + await world.dispose(); + }); + + for (const wasm of [false, true]) { + it(`start once an observer registers (${wasm ? "WASM" : "JS"} kernels)`, async () => { + const world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(seed); + await world.run(bump); + // New observers see everything once. + await world.run(watchChanged); + await world.run(watchChanged); + expect(new Set(await watchedOf(world))).toEqual(new Set([1])); + // Writes after registering are stamped, so the observer sees each exactly once. + await world.run(bump); + await world.run(watchChanged); + await world.run(watchChanged); + expect(new Set(await watchedOf(world))).toEqual(new Set([2])); + await world.dispose(); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/observe.test-helper.ts b/packages/ecs/src/implementation/world/observe.test-helper.ts new file mode 100644 index 000000000..1498a86d5 --- /dev/null +++ b/packages/ecs/src/implementation/world/observe.test-helper.ts @@ -0,0 +1,13 @@ +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; + +const settled = System.create({ name: "observeTest/settled", observer: true }, () => {}); + +/** + * Resolves once every observer factory subscribed before it had its first value: after the factories' flush, which the + * end of this task starts, and a run queued after the runs it makes, which changes nothing. + */ +export const settle = async (world: World): Promise => { + await Promise.resolve(); + await world.run(settled); +}; diff --git a/packages/ecs/src/implementation/world/observed-workers.cases.ts b/packages/ecs/src/implementation/world/observed-workers.cases.ts new file mode 100644 index 000000000..7a692058c --- /dev/null +++ b/packages/ecs/src/implementation/world/observed-workers.cases.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./observed-workers.fixture.js"; + +/** Tick stamps start on workers once observed: the cases, over the fixture built for node or from source. */ +export const observedWorkersCases = (load: () => Promise) => describe("tick stamps only where observed, on workers", () => { + for (const wasm of [false, true]) { + it(`start once an observer registers (${wasm ? "WASM" : "JS"} kernels)`, async () => { + const fixture = await load(); + expect(await fixture.observedOnWorkers(wasm)).toEqual([[1], [2]]); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/observed-workers.fixture.ts b/packages/ecs/src/implementation/world/observed-workers.fixture.ts new file mode 100644 index 000000000..0c4696062 --- /dev/null +++ b/packages/ecs/src/implementation/world/observed-workers.fixture.ts @@ -0,0 +1,55 @@ +import { Component } from "../../interface/component/component.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +/** Scenarios of tick stamps on workers, run built in node and from source in chromium. */ + +const value = Symbol("observedWorkersTest/value"); +const seen = Symbol("observedWorkersTest/seen"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [seen]: { type: number }; + } +} + +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(seen, { type: { type: "number", precision: 1 } }); + +const seed = System.create({ name: "observedWorkersTest/seed" }, ({ commands }) => { + for (let i = 0; i < 3000; i++) commands.spawn([value, seen])({ [value]: i, [seen]: 0 }); +}); + +/** Writes every value: until something observes `value`, its stamps are muted. */ +const bump = System.create({ name: "observedWorkersTest/bump", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); + +const watch = System.create({ name: "observedWorkersTest/watch", kernel: "cpu", components: { write: [seen], changed: [value] }, after: [bump] }, row => { + row[seen] = row[seen] + 1; +}); + +/** The distinct counts each row's observer saw: after it registered, and after one more write. */ +export const observedOnWorkers = async (wasm: boolean): Promise => { + const world = createWorld({ [workers]: 2 }, { wasm, minimumChunk: 64 }); + try { + await world.run(seed); + await world.run(bump); + const seenAll = async (): Promise => [...new Set((await readEach(world, await select(world, { required: [seen] }), seen)).map(each => each ?? -1))]; + // New observers see everything once. + await world.run(watch); + await world.run(watch); + const first = await seenAll(); + // Writes after registering are stamped, so the observer sees each exactly once. + await world.run(bump); + await world.run(watch); + await world.run(watch); + return [first, await seenAll()]; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts b/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts new file mode 100644 index 000000000..be9730515 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts @@ -0,0 +1,40 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { gate, tallies, watchTally } from "./observer-contained.test-helper.js"; + +/** + * A thread joining a world through the port it's sent, which adds the watcher with the gate it's sent, and tells what + * it saw. It says it added the watcher once a run of its own resolved: sent after the add through the same port, so the + * world placed the watcher by that run's sync point, which posts the watcher's first run. + */ + +type Port = MessagePort | NodeMessagePort; +export type ToContainedWorker = + | { readonly kind: "join"; readonly port: Port } + | { readonly kind: "add"; readonly gate: Int32Array } + | { readonly kind: "seen" }; +export type FromContainedWorker = + | { readonly kind: "joined" } + | { readonly kind: "added" } + | { readonly kind: "seen"; readonly tallies: readonly number[] }; + +const channel = scopeChannel(); +let remote: World | null = null; +const placed = System.create({ name: "observerContainedTest/placed", observer: true }, () => {}); + +channel.onMessage(message => { + if ("protocol" in message) return; + if (message.kind === "join") { + remote = join(message.port, {}); + channel.post({ kind: "joined" }); + } + else if (message.kind === "add") { + gate.words = message.gate; + remote?.add(watchTally); + void remote?.run(placed).then(() => channel.post({ kind: "added" })); + } + else channel.post({ kind: "seen", tallies: [...tallies] }); +}); diff --git a/packages/ecs/src/implementation/world/observer-contained.browser.test.ts b/packages/ecs/src/implementation/world/observer-contained.browser.test.ts new file mode 100644 index 000000000..2fc844ebc --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-contained.browser.test.ts @@ -0,0 +1,4 @@ +import { observerContainedCases } from "./observer-contained.cases.js"; + +// A real worker in chromium, over the fixture's source. +observerContainedCases(() => import("./observer-contained.fixture.js")); diff --git a/packages/ecs/src/implementation/world/observer-contained.cases.ts b/packages/ecs/src/implementation/world/observer-contained.cases.ts new file mode 100644 index 000000000..6894c02a7 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-contained.cases.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./observer-contained.fixture.js"; + +/** A joined worker's observer system reading only resources: the cases, over the fixture built for node or from source in chromium. */ +export const observerContainedCases = (load: () => Promise) => describe("observer systems reading only resources on a joined worker", () => { + it("never hold the world's runs while one runs there, take its tick as it will, and run once more for the newest", async () => { + const fixture = await load(); + const seen = await fixture.heldWhileTheWorldRuns(); + // The world ran on while the worker held its run, posting nothing it filters on: it ran once. + expect(seen.quiet).toBe(true); + expect(seen.first).toEqual([0, 1]); + // Bumped twice while it held its run of 2, it ran once more, for the newest. + expect(seen.bumped).toBe(true); + expect(seen.second).toEqual([0, 1, 2, 4]); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/observer-contained.fixture.ts b/packages/ecs/src/implementation/world/observer-contained.fixture.ts new file mode 100644 index 000000000..cc5466ad9 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-contained.fixture.ts @@ -0,0 +1,125 @@ +/// +import { workers } from "../../interface/resources/workers.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import type { FromContainedWorker, ToContainedWorker } from "./observer-contained-worker.fixture.js"; +import { bump, gateWords, nothing, reachedWord, releasedWord, setHolding } from "./observer-contained.test-helper.js"; + +/** + * A joined worker's observer system reading only resources, held partway through a run while the world runs on: what + * the world's runs do meanwhile, and what the worker's runs saw once released. Run built in node, from source in + * chromium. + */ + +type Worker = { readonly ask: (message: ToContainedWorker, transfer?: readonly unknown[]) => Promise; readonly terminate: () => Promise }; + +const workerFile = "./observer-contained-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./observer-contained-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer = []) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => { if (!("protocol" in event.data)) resolve(event.data); }; + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer = []) => new Promise(resolve => { + const reply = (data: FromContainedWorker): void => { + if ("protocol" in data) return; + worker.off("message", reply); + resolve(data); + }; + worker.on("message", reply); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + }; +}; + +const later = (milliseconds = 0): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)); + +const joined = async (world: CreatedWorld): Promise => { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + await worker.ask({ kind: "join", port: port2 }, [port2]); + for (let turn = 0; turn < 2000; turn++) { + let status = ""; + world.threads(progress => { status = progress.at(-1)?.status ?? ""; })(); + if (status === "joined") break; + await later(); + } + return worker; +}; + +/** + * Whether `work` settled while the worker still held its run, which only this thread releases: true once it settles, + * and never for one waiting on the held worker, which times the test out. + */ +const settles = async (words: Int32Array, value: number, work: Promise): Promise => { + await work; + return Atomics.load(words, releasedWord) < value; +}; + +/** Resolves once the held run reached `value`. */ +const reached = async (words: Int32Array, value: number): Promise => { + while (Atomics.load(words, reachedWord) < value) await later(1); +}; + +const release = (words: Int32Array, value: number): void => { + Atomics.store(words, releasedWord, value); + Atomics.notify(words, releasedWord); +}; + +/** + * What the worker saw once it's seen `count` values: a post no later sync point replaces runs however late the thread + * claims it, so this waits as long as that takes. + */ +const seenOnce = async (worker: Worker, count: number): Promise => { + for (;;) { + const reply = await worker.ask({ kind: "seen" }); + const tallies = reply.kind === "seen" ? reply.tallies : []; + if (tallies.length >= count) { + // A few more turns, for a run that shouldn't come to show it didn't. + await later(50); + const again = await worker.ask({ kind: "seen" }); + return again.kind === "seen" ? again.tallies : []; + } + await later(5); + } +}; + +/** + * Once the watcher ran for the tally's first value, 0, it holds its run of 1 while the world runs twice, changing + * nothing it filters on; released, what it saw. Then it holds its run of 2 while the world bumps the tally twice more; + * released, what it saw then. And whether the world's runs settled while it held each time. + */ +export const heldWhileTheWorldRuns = async () => { + const world = createWorld({ [workers]: 0 }); + const worker = await joined(world); + const words = new Int32Array(new SharedArrayBuffer(gateWords * 4)); + // Its first run, which passes its filter, before any is held: posted at the sync point of the worker's run, and run + // before the world's next, which would replace it unclaimed. + await worker.ask({ kind: "add", gate: words }); + await seenOnce(worker, 1); + await world.run(setHolding, { holding: -1 }); + await world.run(bump); + await reached(words, 1); + const quiet = await settles(words, 1, Promise.all([world.run(nothing), world.run(setHolding, { holding: -1 })])); + release(words, 1); + const first = await seenOnce(worker, 2); + await world.run(bump); + await reached(words, 2); + const bumped = await settles(words, 2, Promise.all([world.run(bump), world.run(bump)])); + // Through the newest: the run it holds, and the one after, which holds once it reached it. + release(words, 4); + const second = await seenOnce(worker, 4); + await world.dispose(); + await worker.terminate(); + return { quiet, first, bumped, second }; +}; diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts new file mode 100644 index 000000000..b7f6e8321 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -0,0 +1,43 @@ +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; + +// Keys and systems both threads of the observer-contained fixtures import: a counter the world bumps, and an observer +// system reading only resources that a joined thread adds, which can hold its run until the world's thread releases it. + +export const tally = Symbol("observerContainedTest/tally"); +export const holding = Symbol("observerContainedTest/holding"); +declare module "@adobe/ecs" { + export interface Resources { + [tally]: { type: number }; + [holding]: { type: number }; + } +} +Resource.register(tally, { type: { type: "integer", default: 0 } }); +/** While negative, each run of the watcher says it reached its value and waits until the world's thread releases it. */ +Resource.register(holding, { type: { type: "number", default: 0 } }); + +/** Shared words: the value a held run reached, and the value the world's thread released runs through. */ +export const reachedWord = 0; +export const releasedWord = 1; +export const gateWords = 2; +/** The joined thread's view of the gate, once the world's thread sent it. */ +export const gate: { words: Int32Array | null } = { words: null }; + +/** What the watcher's runs saw, in order. */ +export const tallies: number[] = []; + +export const bump = System.create({ name: "observerContainedTest/bump", resources: { write: [tally] } }, ({ resources }) => { resources[tally] += 1; }); +export const nothing = System.create({ name: "observerContainedTest/nothing" }, () => {}); +export const setHolding = System.create({ name: "observerContainedTest/setHolding", input: { type: "object", properties: { holding: { type: "number" } } }, resources: { write: [holding] } }, ({ input, resources }) => { + resources[holding] = input.holding; +}); + +/** After each sync point that changed the tally: notes it, and, while `holding` says so, waits there to be released. */ +export const watchTally = System.create({ name: "observerContainedTest/watchTally", observer: true, resources: { read: [tally, holding], changed: [tally] } }, ({ resources }) => { + const value = resources[tally]; + tallies.push(value); + const { words } = gate; + if (resources[holding] >= 0 || words === null) return; + Atomics.store(words, reachedWord, value); + for (let released = Atomics.load(words, releasedWord); released < value; released = Atomics.load(words, releasedWord)) Atomics.wait(words, releasedWord, released); +}); diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts new file mode 100644 index 000000000..3fc80894c --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { Resource, System, workers } from "../../index.js"; +import { createWorld, join } from "./create.js"; + +// Observer systems reading only resources on a joined thread, whose posts hold copies of what they read, with the world +// and the thread on this one, joined through a MessageChannel. + +const wide = Symbol("observerContainedTest/wide"); +const paused = Symbol("observerContainedTest/paused"); +const steps = Symbol("observerContainedTest/steps"); +declare module "@adobe/ecs" { + interface Resources { + [wide]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number] }; + [paused]: { type: boolean }; + [steps]: { type: number }; + } +} +/** Twelve f64s: more than a post's block holds, so its posts copy them into a snapshot of their own. */ +Resource.register(wide, { type: { type: "array", items: { type: "number" }, minItems: 12, maxItems: 12, default: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } }); +Resource.register(paused, { type: { type: "boolean", default: false } }); +Resource.register(steps, { type: { type: "number", default: 0 } }); + +const widen = System.create({ name: "observerContainedTest/widen", resources: { write: [wide] } }, ({ resources }) => { + const first = resources[wide][0] + 1; + const base = first * 100; + resources[wide] = [base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11]; +}); +const step = System.create({ name: "observerContainedTest/step", resources: { write: [steps] } }, ({ resources }) => { resources[steps] += 1; }); +const pause = System.create({ name: "observerContainedTest/pause", input: { type: "object", properties: { paused: { type: "boolean" } } }, resources: { write: [paused] } }, ({ input, resources }) => { + resources[paused] = input.paused; +}); + +const seen: { wide: number[][]; steps: number[] } = { wide: [], steps: [] }; +const watchWide = System.create({ name: "observerContainedTest/watchWide", observer: true, resources: { read: [wide], changed: [wide] } }, ({ resources }) => { + seen.wide.push([...resources[wide]]); +}); +const watchSteps = System.create({ name: "observerContainedTest/watchSteps", observer: true, resources: { read: [steps], not: [paused], changed: [steps] } }, ({ resources }) => { + seen.steps.push(resources[steps]); +}); + +const settled = async (done: () => boolean): Promise => { + for (let turn = 0; turn < 2000 && !done(); turn++) await new Promise(resolve => setTimeout(resolve, 0)); +}; +const turns = async (count = 20): Promise => { + for (let turn = 0; turn < count; turn++) await new Promise(resolve => setTimeout(resolve, 0)); +}; + +const joined = async () => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, {}); + for (let turn = 0; turn < 2000; turn++) { + let status = ""; + world.threads(progress => { status = progress.at(-1)?.status ?? ""; })(); + if (status === "joined") break; + await new Promise(resolve => setTimeout(resolve, 0)); + } + await remote.run(System.create({ name: "observerContainedTest/nothing", observer: true }, () => {})); + return { world, remote, close: async () => { await remote.dispose(); await world.dispose(); } }; +}; + +describe("observer systems reading only resources on a joined thread", () => { + it("read resources larger than their block holds from a snapshot of their own", async () => { + seen.wide.length = 0; + const { world, remote, close } = await joined(); + remote.add(watchWide); + await turns(); + await world.run(widen); + await settled(() => seen.wide.at(-1)?.[0] === 100); + await world.run(widen); + await settled(() => seen.wide.at(-1)?.[0] === 10100); + expect(seen.wide.at(-2)).toEqual(Array.from({ length: 12 }, (_, k) => 100 + k)); + expect(seen.wide.at(-1)).toEqual(Array.from({ length: 12 }, (_, k) => 10100 + k)); + await close(); + }); + + it("run for the newest sync point only, which its conditions may not hold at", async () => { + seen.steps.length = 0; + const { world, remote, close } = await joined(); + remote.add(watchSteps); + await turns(); + await world.run(step); + await settled(() => seen.steps.length === 1); + // Behind, the thread runs nothing for the step: paused at the newest. + await Promise.all([world.run(step), world.run(pause, { paused: true })]); + await turns(); + expect(seen.steps).toEqual([1]); + // Unpaused, the step it hasn't run passes its filter. + await world.run(pause, { paused: false }); + await settled(() => seen.steps.length === 2); + await turns(); + expect(seen.steps).toEqual([1, 2]); + await close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/observer-joined-worker.fixture.ts b/packages/ecs/src/implementation/world/observer-joined-worker.fixture.ts new file mode 100644 index 000000000..c20a3dc03 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-joined-worker.fixture.ts @@ -0,0 +1,80 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import type { Entity } from "../../interface/entity/entity.js"; +import type { World } from "../../interface/world/world.js"; +import { join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { countHot, gate, hold, hot, lookRows, looked, mottos, nextPhase, seen, stamp, watchMotto, watchRows, type Seen } from "./observer-joined.test-helper.js"; +import { tailSnapshots } from "./thread-systems.js"; +import { Observe } from "../../observe/index.js"; + +/** + * A thread joining a world through the port it's sent, claiming nothing, which adds one of its own observer systems + * when asked, with the gate its runs wait on; holds its event loop when asked, until the gate releases it; and tells + * what they saw, and how often they read from a copy, when asked. Asked to count what's hot, it runs its own observer + * system counting it, asks the world to move its phase on after, and holds its event loop until the gate releases it, + * so the post for its run waits unclaimed meanwhile; then it tells what that run counted. + */ + +type Port = MessagePort | NodeMessagePort; +export type ToObserverWorker = + | { readonly kind: "join"; readonly port: Port } + | { readonly kind: "add"; readonly system: "rows" | "lookups" | "motto"; readonly gate: Int32Array } + | { readonly kind: "hold" } + | { readonly kind: "count hot"; readonly gate: Int32Array } + | { readonly kind: "seen" }; +export type FromObserverWorker = + | { readonly kind: "joined" } + | { readonly kind: "added" } + | { readonly kind: "holding" } + | { readonly kind: "counted"; readonly hot: number } + | { readonly kind: "seen"; readonly seen: readonly Seen[]; readonly mottos: readonly { readonly round: number; readonly motto: string }[]; readonly switched: number; readonly late: number; readonly timers: number }; + +const channel = scopeChannel(); +let remote: World | null = null; +/** Timers that ran here since the system was added: this thread returns to its event loop while the world ticks. */ +let timers = 0; +const timer = (): void => { + timers++; + setTimeout(timer, 0); +}; + +/** The entities the world holds, once it holds `count`. */ +const entities = (world: World, count: number): Promise => new Promise(resolve => { + let done = false; + const stop = Observe.select(world, { required: [stamp] })(found => { + if (done || found.length < count) return; + done = true; + queueMicrotask(() => stop()); + resolve(found); + }); +}); + +channel.onMessage(async message => { + if ("protocol" in message) return; + if (message.kind === "join") { + remote = join(message.port, {}); + channel.post({ kind: "joined" }); + } + else if (message.kind === "add") { + if (remote === null) return; + gate.words = message.gate; + if (message.system === "lookups") looked.push(...await entities(remote, 1000)); + remote.add(message.system === "rows" ? watchRows : message.system === "motto" ? watchMotto : lookRows); + setTimeout(timer, 0); + channel.post({ kind: "added" }); + } + else if (message.kind === "count hot") { + if (remote === null) return; + gate.words = message.gate; + const counting = remote.run(countHot); + void remote.run(nextPhase); + hold(); + await counting; + channel.post({ kind: "counted", hot: hot.at(-1) ?? -1 }); + } + else if (message.kind === "hold") { + channel.post({ kind: "holding" }); + hold(); + } + else channel.post({ kind: "seen", seen, mottos, switched: tailSnapshots.switched, late: tailSnapshots.late, timers }); +}); diff --git a/packages/ecs/src/implementation/world/observer-joined.cases.ts b/packages/ecs/src/implementation/world/observer-joined.cases.ts new file mode 100644 index 000000000..f1f0ce058 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-joined.cases.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./observer-joined.fixture.js"; + +/** Observer systems a joined worker adds: the cases, over the fixture built for node or from source in chromium. */ +export const observerJoinedCases = (load: () => Promise) => describe("observer systems a joined worker adds", () => { + it("read every row as of one sync point while the world ticks on writing them, from a copy once a writer is ready first", async () => { + const fixture = await load(); + const seen = await fixture.continuous("rows"); + expect({ failures: seen.failures, wrong: seen.wrong, fewest: seen.fewest, most: seen.most }).toEqual({ failures: [], wrong: 0, fewest: 1000, most: 1000 }); + // Each run a writer was ready for partway read the rest from its copy. + expect(seen.partway?.released.length).toBe(5); + expect(seen.partway?.switched).toBe(5); + // Held, it fell behind, and ran once, for the newest sync point. + expect(seen.behind?.behind).toEqual([seen.behind?.newest]); + // Its thread returned to its event loop meanwhile. + expect(seen.timers).toBeGreaterThan(0); + }, 30000); + it("look every row up as of one sync point while the world ticks on writing them, the writers waiting for it", async () => { + const fixture = await load(); + const seen = await fixture.continuous("lookups"); + expect({ failures: seen.failures, wrong: seen.wrong, fewest: seen.fewest, most: seen.most }).toEqual({ failures: [], wrong: 0, fewest: 1000, most: 1000 }); + expect(seen.runs).toBeGreaterThan(10); + }, 30000); + it("read a string resource as of their sync point, though the world's runs go on writing it while one runs", async () => { + const fixture = await load(); + expect(await fixture.mottoHeld()).toEqual([{ round: 1, motto: "motto 1" }]); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/observer-joined.fixture.ts b/packages/ecs/src/implementation/world/observer-joined.fixture.ts new file mode 100644 index 000000000..dd95ab078 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-joined.fixture.ts @@ -0,0 +1,286 @@ +/// +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import type { LoopPlatform } from "./loop.js"; +import type { FromObserverWorker, ToObserverWorker } from "./observer-joined-worker.fixture.js"; +import { counter, gateWords, holdWord, phase, reachedWord, releasedWord, seed, setMotto, setStall, spawnHeated, ticked, warm } from "./observer-joined.test-helper.js"; +import { tailWriters } from "./tails.js"; +import { followReports } from "./reports.test-helper.js"; + +/** + * Scenarios of observer systems a joined worker adds, run built in node and from source in chromium: a system over + * every row, and one looking every row up by entity, while the world ticks on writing them, prompt and stalling + * partway so the world's next writer is likely ready before they finish; then, for the system over every row, each + * order forced, whatever the machine's timing: a writer ready while a run is partway, and a thread that fell behind. + */ + +type Worker = { readonly ask: (message: ToObserverWorker, transfer?: readonly unknown[]) => Promise; readonly terminate: () => Promise }; + +const workerFile = "./observer-joined-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./observer-joined-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer = []) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => { if (!("protocol" in event.data)) resolve(event.data); }; + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer = []) => new Promise(resolve => { + const reply = (data: FromObserverWorker): void => { + if ("protocol" in data) return; + worker.off("message", reply); + resolve(data); + }; + worker.on("message", reply); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + }; +}; + +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +const joined = async (world: CreatedWorld): Promise => { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + await worker.ask({ kind: "join", port: port2 }, [port2]); + await until(world.threads, threads => threads.at(-1)?.status === "joined"); + return worker; +}; + +/** + * Frames back to back, as `backToBack`, until `gate`: from then on, a tick starts only when `next` says, and `asked` + * resolves once the loop asked for its next beat, so the last tick's sync point and posts are done. + */ +const steppedFrames = (): Partial & { readonly gate: () => void; readonly asked: () => Promise; readonly next: () => Promise; readonly close: () => void } => { + const channel = new MessageChannel(); + let pending: ((time: number) => void) | null = null; + let requested: (() => void) | null = null; + let gated = false; + const call = (): void => { + const callback = pending; + pending = null; + callback?.(performance.now()); + }; + channel.port1.onmessage = () => { if (!gated) call(); }; + const asked = async (): Promise => { + if (pending === null) await new Promise(resolve => { requested = resolve; }); + }; + return { + frame: callback => { + pending = callback; + requested?.(); + requested = null; + if (!gated) channel.port2.postMessage(null); + return () => { + if (pending === callback) pending = null; + }; + }, + gate: () => { gated = true; }, + asked, + next: async () => { + await asked(); + call(); + }, + close: () => { + channel.port1.close(); + channel.port2.close(); + }, + }; +}; + +/** A task later, so other threads' messages and the world's waits settle between checks. */ +const later = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); + +/** Ticks on until the counter passed `more` more. */ +const ticks = async (world: World, more: number): Promise => { + const now = await until(Observe.resource(world, counter), () => true); + await until(Observe.resource(world, counter), value => value >= now + more); +}; + +/** What the worker's observer system saw so far. */ +const seenBy = async (worker: Worker): Promise> => { + const reply = await worker.ask({ kind: "seen" }); + if (reply.kind !== "seen") throw new Error("observer-joined: the worker didn't say what it saw."); + return reply; +}; + +/** + * Gated, a run waiting partway, at the end of its first chunk, as each next step of the world starts: a tick, four + * times, then a run setting the stall back. Each run is released once the step's writer copied the rest, so it reads + * that from the copy. Returns the runs released and the runs that read from a copy meanwhile. + */ +const writerReadyPartway = async (world: World, worker: Worker, frames: ReturnType, words: Int32Array) => { + // Counted from once the last tick's run is done, so a late run of the stalled ticks before isn't among them. + await frames.asked(); + const newest = await until(Observe.resource(world, counter), () => true); + while (!(await seenBy(worker)).seen.some(run => run.counter === newest && run.rows === 1000)) await later(); + const switched = (await seenBy(worker)).switched; + let released = Atomics.load(words, releasedWord); + const releases: number[] = []; + const step = async (start: () => Promise): Promise => { + while (Atomics.load(words, reachedWord) <= released) await later(); + const reached = Atomics.load(words, reachedWord); + const copies = tailWriters.copies; + const started = start(); + while (tailWriters.copies === copies) await later(); + released = reached; + releases.push(reached); + Atomics.store(words, releasedWord, reached); + Atomics.notify(words, releasedWord); + await started; + }; + await world.run(setStall, { pause: -1 }); + for (let k = 0; k < 4; k++) await step(frames.next); + await step(() => world.run(setStall, { pause: 0 })); + return { released: releases, switched: (await seenBy(worker)).switched - switched }; +}; + +/** + * Gated, the thread holds its event loop while the world ticks three times, then is released: it fell behind, and + * runs once, for the newest sync point. Returns the counters of the runs after the hold began, and the newest. + */ +const fellBehind = async (world: World, worker: Worker, frames: ReturnType, words: Int32Array) => { + await worker.ask({ kind: "hold" }); + const from = await until(Observe.resource(world, counter), () => true); + for (let k = 0; k < 3; k++) await frames.next(); + await frames.asked(); + const newest = from + 3; + Atomics.store(words, holdWord, 1); + Atomics.notify(words, holdWord); + for (;;) { + const { seen } = await seenBy(worker); + if (seen.some(run => run.counter === newest && run.rows === 1000)) return { behind: seen.filter(run => run.counter > from).map(run => run.counter), newest }; + await later(); + } +}; + +/** + * 1,000 rows while the world ticks back to back writing them, the worker's observer system over them prompt, then + * stalling partway, then prompt again; then, for the system over every row, gated, a writer ready while a run is + * partway, and the thread falling behind: what each run saw, how many ran from a copy, and how many ran. + */ +export const continuous = async (system: "rows" | "lookups") => { + const frames = steppedFrames(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: frames }); + const worker = await joined(world); + const words = new Int32Array(new SharedArrayBuffer(gateWords * 4)); + // What the worker's runs threw, which reach the world's reports. + const reports = followReports(world); + await world.run(seed, { count: 1000 }); + world.add(ticked); + await worker.ask({ kind: "add", system, gate: words }); + await ticks(world, 30); + await world.run(setStall, { pause: 3 }); + await ticks(world, 30); + await world.run(setStall, { pause: 0 }); + await ticks(world, 10); + let partway: Awaited> | null = null; + let behind: Awaited> | null = null; + if (system === "rows") { + frames.gate(); + partway = await writerReadyPartway(world, worker, frames, words); + behind = await fellBehind(world, worker, frames, words); + } + const failures = reports.seen.flatMap(report => report.failures.map(failure => failure.message)); + await world.dispose(); + const reply = await seenBy(worker); + await worker.terminate(); + frames.close(); + const { seen } = reply; + return { + failures: [...new Set(failures)], + runs: seen.length, + wrong: seen.reduce((sum, run) => sum + run.wrong, 0), + fewest: Math.min(...seen.map(run => run.rows)), + most: Math.max(...seen.map(run => run.rows)), + timers: reply.timers, + partway, + behind, + }; +}; + +/** + * In chromium, with a GPU device: 100 rows a GPU kernel warms by 100 a run, and the worker's observer system counting + * those at least 150 hot through a sorted index on what it writes, each count run by the worker before a warming run + * whose sync point finds its post unclaimed, the worker holding: posted again after it, it's counted by the values the + * kernel wrote. What each count gave. + */ +export const gpuLookups = async (device: GPUDevice): Promise => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + const worker = await joined(world); + const words = new Int32Array(new SharedArrayBuffer(gateWords * 4)); + await world.run(spawnHeated); + const counts: number[] = []; + for (let k = 1; k <= 2; k++) { + Atomics.store(words, holdWord, 0); + const counted = worker.ask({ kind: "count hot", gate: words }); + await until(Observe.resource(world, phase), value => value >= k); + await world.run(warm); + Atomics.store(words, holdWord, 1); + Atomics.notify(words, holdWord); + const reply = await counted; + counts.push(reply.kind === "counted" ? reply.hot : -1); + } + await world.dispose(); + await worker.terminate(); + return counts; +}; + +/** + * The worker's observer system reading only resources, a string among them, waiting on the gate as it runs for round 1 + * while the world's runs set six more mottos, each of one size, so the record its round's motto was in would be freed + * and reused were they to go on past it. What it read for round 1, once released. + */ +export const mottoHeld = async (): Promise => { + const world = createWorld({ [workers]: 0 }); + const worker = await joined(world); + const words = new Int32Array(new SharedArrayBuffer(gateWords * 4)); + // A post of round 0, before the first motto, passes the gate. + Atomics.store(words, releasedWord, 1); + await world.run(setStall, { pause: -1 }); + await worker.ask({ kind: "add", system: "motto", gate: words }); + // The world takes up the thread's system once its message arrives: runs of round 0 until a post of it ran. + for (;;) { + await world.run(setMotto, { round: 0 }); + const reply = await worker.ask({ kind: "seen" }); + if (reply.kind === "seen" && reply.mottos.length > 0) break; + await later(); + } + await world.run(setMotto, { round: 1 }); + while (Atomics.load(words, reachedWord) < 2) await later(); + const runs = Array.from({ length: 6 }, (_, k) => world.run(setMotto, { round: k + 2 })); + await new Promise(resolve => setTimeout(resolve, 100)); + Atomics.store(words, releasedWord, 1000); + Atomics.notify(words, releasedWord); + await Promise.all(runs); + let read: readonly { readonly round: number; readonly motto: string }[] = []; + for (let k = 0; k < 200 && !read.some(entry => entry.round === 1); k++) { + const reply = await worker.ask({ kind: "seen" }); + if (reply.kind === "seen") read = reply.mottos; + await later(); + } + await world.dispose(); + await worker.terminate(); + return read.filter(entry => entry.round === 1); +}; diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts new file mode 100644 index 000000000..703dabb4f --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -0,0 +1,180 @@ +import { Component } from "../../interface/component/component.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import type { Entity } from "../../interface/entity/entity.js"; + +// Keys and systems both threads of the observer-joined fixtures import: world systems writing every row's stamp each +// tick, and observer systems a joined thread adds, reading those rows as of one sync point. + +export const stamp = Symbol("observerJoinedWorkerTest/stamp"); +export const spot = Symbol("observerJoinedWorkerTest/spot"); +export const counter = Symbol("observerJoinedWorkerTest/counter"); +export const stall = Symbol("observerJoinedWorkerTest/stall"); +export const heat = Symbol("observerJoinedWorkerTest/heat"); +export const rate = Symbol("observerJoinedWorkerTest/rate"); +export const phase = Symbol("observerJoinedWorkerTest/phase"); +export const word = Symbol("observerJoinedWorkerTest/word"); +export const motto = Symbol("observerJoinedWorkerTest/motto"); +export const mottoRound = Symbol("observerJoinedWorkerTest/mottoRound"); +declare module "@adobe/ecs" { + export interface Components { + [stamp]: { type: number }; + [spot]: { type: readonly [number, number, number] }; + [heat]: { type: number }; + [rate]: { type: number }; + [word]: { type: string }; + } + export interface Resources { + [counter]: { type: number }; + [stall]: { type: number }; + [phase]: { type: number }; + [motto]: { type: string }; + [mottoRound]: { type: number }; + } +} +Component.register(stamp, { type: { type: "integer" } }); +Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); +Component.register(heat, { type: { type: "number", precision: 1 } }); +Component.register(rate, { type: { type: "number", precision: 1 } }); +/** A string the world writes each tick, which lives in shared memory where the observer system reads it. */ +Component.register(word, { type: { type: "string" } }); +Resource.register(counter, { type: { type: "integer", default: 0 } }); +/** A string resource the world writes with the round it's of, which an observer system on the thread reads alone. */ +Resource.register(motto, { type: { type: "string", default: "motto 0" } }); +Resource.register(mottoRound, { type: { type: "integer", default: 0 } }); +/** Moves on once a joined thread asked the world to run its observer system, so the world's thread knows it did. */ +Resource.register(phase, { type: { type: "integer", default: 0 } }); +/** + * Milliseconds the observer systems spin partway through each run, so the world's next writer is likely ready before + * they finish; negative, they wait there on the gate instead, until the world's thread releases them once a writer + * copied. + */ +Resource.register(stall, { type: { type: "number", default: 0 } }); + +const spin = (milliseconds: number): void => { + const until = performance.now() + milliseconds; + while (performance.now() < until); +}; + +/** + * Shared words the world's thread and the joined thread order their steps by: the counter a run waits partway at, the + * counter the world's thread released runs through, and, once nonzero, the release of a thread holding its event loop. + */ +export const reachedWord = 0; +export const releasedWord = 1; +export const holdWord = 2; +export const gateWords = 3; +/** The joined thread's view of the gate, once the world's thread sent it. */ +export const gate: { words: Int32Array | null } = { words: null }; + +/** Spins for `pause` milliseconds, or, negative, says it reached `value` and waits until it's released. */ +const pauseAt = (pause: number, value: number): void => { + const { words } = gate; + if (pause >= 0 || words === null) { + spin(pause); + return; + } + Atomics.store(words, reachedWord, value); + for (let released = Atomics.load(words, releasedWord); released < value; released = Atomics.load(words, releasedWord)) Atomics.wait(words, releasedWord, released); +}; + +/** Holds the joined thread's event loop until the world's thread releases it. */ +export const hold = (): void => { + const { words } = gate; + if (words === null) return; + while (Atomics.load(words, holdWord) === 0) Atomics.wait(words, holdWord, 0); +}; + +/** A tick's word, of a length that varies with it, so records of every size are freed and reused. */ +export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; + +/** On the world's thread: the counter moves on once a tick, then every row's stamp, spot and word take it. */ +export const count = System.create({ name: "observerJoinedWorkerTest/count", resources: { write: [counter] } }, ({ resources }) => { + resources[counter] += 1; +}); +export const advance = System.create({ name: "observerJoinedWorkerTest/advance", components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { + const value = resources[counter]; + row[stamp] = value; + row[spot] = [value, -value, value]; + row[word] = wordOf(value); +}); +export const ticked = System.create({ name: "observerJoinedWorkerTest/ticked", systems: [count, advance] }); +export const seed = System.create({ name: "observerJoinedWorkerTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); +}); +export const setStall = System.create({ name: "observerJoinedWorkerTest/setStall", input: { type: "object", properties: { pause: { type: "number" } } }, resources: { write: [stall] } }, ({ input, resources }) => { + resources[stall] = input.pause; +}); + +/** What each run of an observer system saw: the counter, rows, and those not of that tick. */ +export type Seen = { counter: number; rows: number; wrong: number }; +export const seen: Seen[] = []; + +/** Reads every row, which must all be of one tick, pausing at the end of its first chunk while `stall` says so. */ +export const watchRows = System.create({ name: "observerJoinedWorkerTest/watchRows", observer: true, components: { read: [stamp, spot, word] }, resources: { read: [counter, stall] } }, (row, { resources }) => { + const value = resources[counter]; + let last = seen.at(-1); + if (last === undefined || last.counter !== value || last.rows === 1000) seen.push(last = { counter: value, rows: 0, wrong: 0 }); + const [x, y, z] = row[spot]; + if (row[stamp] !== value || x !== value || y !== -value || z !== value || row[word] !== wordOf(value)) last.wrong++; + last.rows++; + if (last.rows === 63) pauseAt(resources[stall], value); +}); + +/** The entities `lookRows` looks up, which the joined thread fills in. */ +export const looked: Entity[] = []; + +/** Looks up every row by entity, which must all be of one tick, spinning halfway while `stall` says so. */ +export const lookRows = System.create({ name: "observerJoinedWorkerTest/lookRows", observer: true, resources: { read: [counter, stall] }, lookup: { found: { read: [stamp, spot] } } }, ({ resources, lookup }) => { + const value = resources[counter]; + const run: Seen = { counter: value, rows: 0, wrong: 0 }; + seen.push(run); + for (let k = 0; k < looked.length; k++) { + const found = lookup.found(looked[k]); + if (found === null) continue; + const [x, y, z] = found[spot]; + if (found[stamp] !== value || x !== value || y !== -value || z !== value) run.wrong++; + run.rows++; + if (k === looked.length >> 1) spin(resources[stall]); + } +}); + +/** 100 rows heated 0 to 99, which a GPU kernel warms by 100 each run. */ +export const spawnHeated = System.create({ name: "observerJoinedWorkerTest/spawnHeated" }, ({ commands }) => { + for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); +}); +export const warm = System.create({ name: "observerJoinedWorkerTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { + row[heat] = row[heat] + row[rate] * 100; +}); +export const nextPhase = System.create({ name: "observerJoinedWorkerTest/nextPhase", resources: { write: [phase] } }, ({ resources }) => { + resources[phase] += 1; +}); + +/** What `countHot` counted, run by run, on the joined thread. */ +export const hot: number[] = []; +export const byHeat = Index.create({ name: "observerJoinedWorkerTest/byHeat", key: [heat], sorted: true }); +/** Counts the rows at least 150 hot, through the sorted index on what the GPU kernel writes. */ +export const countHot = System.create({ name: "observerJoinedWorkerTest/countHot", observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { + hot.push(lookup.hot({ ">=": 150 }).length); +}); + +/** On the world's thread: the motto of a round, and the round, every motto of one size. */ +export const mottoOf = (round: number): string => `motto ${round % 10}`; +export const setMotto = System.create({ name: "observerJoinedWorkerTest/setMotto", input: { type: "object", properties: { round: { type: "integer" } } }, resources: { write: [motto, mottoRound] } }, ({ input, resources }) => { + resources[motto] = mottoOf(input.round); + resources[mottoRound] = input.round; +}); + +/** What each run of `watchMotto` read, by round. */ +export const mottos: { readonly round: number; readonly motto: string }[] = []; + +/** + * Reads only resources, a string among them: waits on the gate first, while `stall` says so, its round plus one, then + * reads the motto, which must be its round's. + */ +export const watchMotto = System.create({ name: "observerJoinedWorkerTest/watchMotto", observer: true, resources: { read: [motto, mottoRound, stall] } }, ({ resources }) => { + const round = resources[mottoRound]; + pauseAt(resources[stall], round + 1); + mottos.push({ round, motto: resources[motto] }); +}); diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts new file mode 100644 index 000000000..818bf233c --- /dev/null +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; +import { Component, Index, Resource, System, workers } from "../../index.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { tablesOf } from "./join.js"; +import { Observe } from "../../observe/index.js"; +import { reportOf, runReported } from "./reports.test-helper.js"; + +// Observer systems on a joined thread, with the world and the thread on this one, joined through a MessageChannel. + +const level = Symbol("observerJoinedTest/level"); +const amount = Symbol("observerJoinedTest/amount"); +const cell = Symbol("observerJoinedTest/cell"); +const tagged = Symbol("observerJoinedTest/tagged"); +const label = Symbol("observerJoinedTest/label"); +const name = Symbol("observerJoinedTest/name"); +declare module "@adobe/ecs" { + interface Threads { + observerJoinedTest: true; + } + interface Components { + [amount]: { type: number }; + [cell]: { type: number }; + [tagged]: { type: true }; + [label]: { type: { readonly text: string }; thread: "observerJoinedTest" }; + [name]: { type: string }; + } + interface Resources { + [level]: { type: number }; + } +} +Component.register(amount, { type: { type: "number" } }); +Component.register(cell, { type: { type: "number" } }); +Component.register(tagged, { type: { const: true } }); +/** The label's start, which a command here gives it by this value. */ +const blank = { text: "" }; +Component.register(label, { type: { type: "object", properties: { text: { type: "string" } }, default: blank }, thread: "observerJoinedTest" }); +Component.register(name, { type: { type: "string", default: "" } }); +Resource.register(level, { type: { type: "number", default: 0 } }); + +const byCell = Index.create({ name: "observerJoinedTest/byCell", key: [cell], components: { has: [amount] } }); +const allTagged = Index.create({ name: "observerJoinedTest/allTagged", key: [], components: { has: [tagged] } }); + +const raise = System.create({ name: "observerJoinedTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const nothing = System.create({ name: "observerJoinedTest/nothing" }, () => {}); +const spawn = System.create({ name: "observerJoinedTest/spawn", input: { type: "object", properties: { at: { type: "number" } } } }, ({ input, commands }) => { + commands.spawn([amount, cell, tagged])({ [amount]: input.at, [cell]: input.at }); +}); +const spawnLabelled = System.create({ name: "observerJoinedTest/spawnLabelled" }, ({ commands }) => { commands.spawn([amount, label])({ [amount]: 4, [label]: blank }); }); +const grow = System.create({ name: "observerJoinedTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); +const move = System.create({ name: "observerJoinedTest/move", components: { write: [cell] } }, row => { row[cell] += 10; }); + +/** What the joined thread's observer bodies saw, reset by each test. */ +const seen: { levels: number[]; amounts: number[][]; looked: (number | boolean)[][]; tagged: number[]; labels: string[]; calls: number } = { levels: [], amounts: [], looked: [], tagged: [], labels: [], calls: 0 }; +const reset = (): void => { + seen.levels.length = seen.amounts.length = seen.looked.length = seen.tagged.length = seen.labels.length = 0; + seen.calls = 0; +}; + +/** The entities the tests look up, by the joined thread's systems. */ +const watched: Entity[] = []; + +const watchLevel = System.create({ name: "observerJoinedTest/watchLevel", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { + seen.calls++; + seen.levels.push(resources[level]); +}); +const watchAmounts = System.create({ name: "observerJoinedTest/watchAmounts", observer: true, components: { read: [amount], changed: [amount] } }, row => { + (seen.amounts[seen.amounts.length - 1] ??= []).push(row[amount]); +}); +const startAmounts = System.create({ name: "observerJoinedTest/startAmounts", observer: true }, () => { seen.amounts.push([]); }); +const amountsPass = System.create([startAmounts, watchAmounts]); +const lookChanged = System.create({ name: "observerJoinedTest/lookChanged", observer: true, lookup: { found: { read: [amount], changed: [amount] } } }, ({ lookup }) => { + seen.looked.push(watched.flatMap(entity => { + const row = lookup.found(entity); + return row === null ? [] : [row[amount], row.changed]; + })); +}); +const countTagged = System.create({ name: "observerJoinedTest/countTagged", observer: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { + seen.tagged.push(lookup.all().length); +}); +const inCell = System.create({ name: "observerJoinedTest/inCell", observer: true, lookup: { found: { index: byCell, read: [amount] } } }, ({ lookup }) => { + const found = lookup.found(11); + seen.tagged.push(found.length === 0 ? 0 : found.length * 100 + (found.at(0)?.[amount] ?? -1)); +}); +const failing = System.create({ name: "observerJoinedTest/failing", observer: true, resources: { read: [level] } }, ({ resources }) => { + if (resources[level] > 1) throw new Error("observer joined failure"); +}); +const readLabels = System.create({ name: "observerJoinedTest/readLabels", observer: true, components: { read: [label, amount] } }, row => { + seen.labels.push(`${row[label].text}${row[amount]}`); +}); + +/** Resolves once `done` holds, between turns of the event loop, which the joined thread's work needs. */ +const settled = async (done: () => boolean): Promise => { + for (let turn = 0; turn < 2000 && !done(); turn++) await new Promise(resolve => setTimeout(resolve, 0)); +}; +/** A few turns of the event loop, for work that should not happen to show it didn't. */ +const turns = async (count = 20): Promise => { + for (let turn = 0; turn < count; turn++) await new Promise(resolve => setTimeout(resolve, 0)); +}; + +const joined = async (threads: readonly "observerJoinedTest"[] = []): Promise<{ readonly world: CreatedWorld; readonly remote: World; readonly close: () => Promise }> => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads }); + for (let turn = 0; turn < 2000; turn++) { + let status = ""; + world.threads(progress => { status = progress.at(-1)?.status ?? ""; })(); + if (status === "joined") break; + await new Promise(resolve => setTimeout(resolve, 0)); + } + // A run's round trip: the thread's claims are made, and it's set up. + await remote.run(nothing); + return { + world, remote, + close: async () => { + await remote.dispose(); + await world.dispose(); + }, + }; +}; + +describe("observer systems on a joined thread", () => { + it("run there after each sync point, reading resources as of it, only when a filter may pass", async () => { + reset(); + const { world, remote, close } = await joined(); + remote.add(watchLevel); + await turns(); + await world.run(raise); + await settled(() => seen.levels.length === 1); + await world.run(raise); + await settled(() => seen.levels.length === 2); + // Nothing it filters on changed: the thread isn't woken for it. + await world.run(nothing); + await turns(); + expect(seen.levels).toEqual([1, 2]); + await close(); + }); + + it("run once, for the newest sync point, after falling behind", async () => { + reset(); + const { world, remote, close } = await joined(); + remote.add(watchLevel); + await turns(); + await Promise.all([world.run(raise), world.run(raise), world.run(raise)]); + await settled(() => seen.levels.length >= 1); + await turns(); + expect(seen.levels).toEqual([3]); + await close(); + }); + + it("read rows with change filters, and composites of observer systems", async () => { + reset(); + const { world, remote, close } = await joined(); + remote.add(amountsPass); + await turns(); + await world.run(spawn, { at: 1 }); + await settled(() => seen.amounts.length === 1); + await world.run(spawn, { at: 5 }); + await settled(() => seen.amounts.length === 2); + await world.run(grow); + await settled(() => seen.amounts.length === 3); + expect(seen.amounts).toEqual([[1], [5], [2, 6]]); + await close(); + }); + + it("look up by entity with changed, and through keyless and keyed indexes, in shared memory", async () => { + reset(); + const { world, remote, close } = await joined(); + await world.run(spawn, { at: 1 }); + await world.run(spawn, { at: 2 }); + const [first, second] = await new Promise(resolve => { + const stop = Observe.select(world, { required: [amount], order: { [amount]: true } })(entities => { + if (entities.length < 2) return; + queueMicrotask(() => stop()); + resolve(entities); + }); + }); + watched.length = 0; + watched.push(first, second); + remote.add(lookChanged); + remote.add(countTagged); + await turns(); + await world.run(nothing); + await settled(() => seen.looked.length === 1 && seen.tagged.length === 1); + await world.run(grow); + await settled(() => seen.looked.length === 2); + await world.run(spawn, { at: 3 }); + await settled(() => seen.tagged.at(-1) === 3); + expect(seen.looked.slice(0, 2)).toEqual([[1, true, 2, true], [2, true, 3, true]]); + // Nothing they read changed since they last ran. + expect(seen.looked.at(-1)).toEqual([2, false, 3, false]); + expect([seen.tagged[0], seen.tagged.at(-1)]).toEqual([2, 3]); + remote.remove(lookChanged); + remote.remove(countTagged); + seen.tagged.length = 0; + remote.add(inCell); + await turns(); + await world.run(move); + await settled(() => seen.tagged.length === 1); + // Cells 1, 2 and 3 moved to 11, 12 and 13; the first's amount grew to 2. + expect(seen.tagged).toEqual([102]); + await close(); + }); + + it("look up through indexes without ids, which the world makes from their declarations", async () => { + reset(); + const { world, remote, close } = await joined(); + await world.run(spawn, { at: 1 }); + await world.run(spawn, { at: 11 }); + // Made in a function, so the build gives them no id. + const made = (): readonly [Index<{ readonly key: readonly []; readonly components: { readonly has: readonly [typeof tagged, typeof cell] } }>, Index<{ readonly key: readonly [typeof cell]; readonly sorted: true; readonly components: { readonly has: readonly [typeof amount] } }>] => [ + Index.create({ name: "observerJoinedTest/madeTagged", key: [], components: { has: [tagged, cell] } }), + Index.create({ name: "observerJoinedTest/madeByCell", key: [cell], sorted: true, components: { has: [amount] } }), + ]; + const [madeTagged, madeByCell] = made(); + const sizes = System.create({ name: "observerJoinedTest/sizes", observer: true, lookup: { all: { index: madeTagged, changed: [cell] }, cells: { index: madeByCell, read: [amount] } } }, ({ lookup }) => { + const all = lookup.all(); + seen.looked.push([all.changed]); + const high = lookup.cells({ ">=": 5 }); + seen.tagged.push(all.length * 1000 + high.length * 100 + (high.at(0)?.[amount] ?? 0)); + }); + expect(await reportOf(world, await remote.run(sizes))).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + await world.run(move); + expect(await reportOf(world, await remote.run(sizes))).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + // Two tagged; then one cell at 11 or more, holding 11, then both, the first holding 1. + expect(seen.tagged).toEqual([2111, 2201]); + expect(seen.looked).toEqual([[true], [true]]); + await close(); + }); + + it("run from the joined thread, resolving once its body ran there, with its failure in a later run's report", async () => { + reset(); + const { world, remote, close } = await joined(); + await world.run(raise); + expect(await reportOf(world, await remote.run(watchLevel))).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + expect(seen.levels).toEqual([1]); + await world.run(raise); + await remote.run(failing); + // Its failure reaches the world by message, in time for a later run. + let report = await runReported(world, nothing); + for (let k = 0; k < 100 && report.failures.length === 0; k++) { + await new Promise(resolve => setTimeout(resolve, 1)); + report = await runReported(world, nothing); + } + expect(report.failures.map(failure => [failure.kind, failure.message])).toEqual([["system", "observer joined failure"]]); + await close(); + }); + + it("report what added ones throw in a later run's report", async () => { + reset(); + const { world, remote, close } = await joined(); + remote.add(failing); + await turns(); + await world.run(raise); + await world.run(raise); + await turns(); + const report = await runReported(world, nothing); + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "observerJoinedTest/failing", "observer joined failure"]]); + await close(); + }); + + it("never run once removed, and free what both threads held", async () => { + reset(); + const { world, remote, close } = await joined(); + const cycle = async (): Promise => { + remote.add(watchLevel); + await turns(); + const from = seen.levels.length; + await world.run(raise); + await settled(() => seen.levels.length === from + 1); + remote.remove(watchLevel); + await world.run(raise); + await turns(); + expect(seen.levels.length).toBe(from + 1); + await world.run(nothing); + await turns(); + await world.run(nothing); + const tables = await world.tables(); + return [tables.arena, tables.runners, tablesOf.get(remote)?.().observer ?? -1]; + }; + const first = await cycle(); + expect(first[2]).toBe(0); + expect(await cycle()).toEqual(first); + await close(); + }); + + it("refuse only systems that aren't observer", async () => { + const { remote, close } = await joined(); + expect(() => remote.add(raise)).toThrow("A joined thread adds only observer systems"); + expect(() => remote.remove(raise)).toThrow("A joined thread removes only observer systems"); + await close(); + }); + + it("are placed on the joined thread filling their objects, reading them there", async () => { + reset(); + const { world, close } = await joined(["observerJoinedTest"]); + await world.run(spawnLabelled); + world.add(readLabels); + await turns(); + await world.run(nothing); + await settled(() => seen.labels.length === 1); + expect(seen.labels).toEqual(["4"]); + world.remove(readLabels); + await close(); + }); + + it("fail to register when placed on the joined thread filling their objects without an id, which it can't name", async () => { + reset(); + const { world, close } = await joined(["observerJoinedTest"]); + await world.run(spawnLabelled); + // Made in a function, so the build gives it no id. + const made = () => System.create({ name: "observerJoinedTest/madeLabels", observer: true, components: { read: [label, amount] } }, row => { + seen.labels.push(`${row[label].text}${row[amount]}`); + }); + const madeLabels = made(); + await expect(world.run(madeLabels)).rejects.toThrow(`System "observerJoinedTest/madeLabels" runs on thread "observerJoinedTest", which a joined thread fills, and has no id, so that thread can't name it`); + // Another of that name, numbered. + const added = made(); + world.add(added); + await turns(); + const report = await runReported(world, nothing); + expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["system", "observerJoinedTest/madeLabels#2"]]); + world.remove(added); + await turns(); + expect(seen.labels).toEqual([]); + await close(); + }); + + it("run on the joined thread filling their objects when the world runs them, then here once it's gone", async () => { + reset(); + const { world, remote } = await joined(["observerJoinedTest"]); + await world.run(spawnLabelled); + expect(await runReported(world, readLabels)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + await settled(() => seen.labels.length === 1); + expect(seen.labels).toEqual(["4"]); + remote.add(watchLevel); + await turns(); + await remote.dispose(); + await turns(); + // The thread's own are forgotten with it; those on the threads it filled run here again. + await world.run(raise); + await world.run(readLabels); + await turns(); + expect([seen.labels, seen.levels]).toEqual([["4", "4"], []]); + expect((await world.tables()).joined).toEqual([]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/observer.browser.test.ts b/packages/ecs/src/implementation/world/observer.browser.test.ts new file mode 100644 index 000000000..06f858f4b --- /dev/null +++ b/packages/ecs/src/implementation/world/observer.browser.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; + +const heat = Symbol("observerGpuTest/heat"); +const rate = Symbol("observerGpuTest/rate"); + +declare module "@adobe/ecs" { + export interface Components { + [heat]: { type: number }; + [rate]: { type: number }; + } +} + +Component.register(heat, { type: { type: "number", precision: 1 } }); +Component.register(rate, { type: { type: "number", precision: 1 } }); + +const spawn = System.create({ name: "observerGpuTest/spawn" }, ({ commands }) => { + for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); +}); +const warm = System.create({ name: "observerGpuTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { + row[heat] = row[heat] + row[rate] * 100; +}); + +let total = 0; +const sum = System.create({ name: "observerGpuTest/sum", observer: true, components: { read: [heat] } }, row => { total += row[heat]; }); + +const byHeat = Index.create({ name: "observerGpuTest/byHeat", key: [heat], sorted: true }); +let hot = 0; +const countHot = System.create({ name: "observerGpuTest/countHot", observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { + hot = lookup.hot({ ">=": 150 }).length; +}); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +describe("observer systems over GPU kernels' writes", () => { + it("read what a GPU kernel wrote in the run, read back for them", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + await world.run(spawn); + world.add(sum); + total = 0; + await world.run(warm); + expect(total).toBe(4950 + 100 * 100); + total = 0; + await world.run(sum); + expect(total).toBe(4950 + 100 * 100); + await world.dispose(); + }); + + it("look up through indexes kept by what a GPU kernel wrote in the run, read back before they're refreshed", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + await world.run(spawn); + world.add(countHot); + await world.run(warm); + expect(hot).toBe(50); + await world.run(warm); + expect(hot).toBe(100); + await world.dispose(); + }); + + it("look up on a joined thread through indexes kept by what a GPU kernel wrote in the run, read back before they're refreshed", async () => { + const fixture = await import("./observer-joined.fixture.js"); + expect(await fixture.gpuLookups(await device())).toEqual([50, 100]); + }); +}); diff --git a/packages/ecs/src/implementation/world/observer.test.ts b/packages/ecs/src/implementation/world/observer.test.ts new file mode 100644 index 000000000..255d27f82 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from "vitest"; +import { Component, Index, Report, Resource, System, workers } from "../../index.js"; +import { isObserver, requireDefinition } from "../../interface/registry/index.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld } from "./create.js"; +import { tablesAfterCollecting } from "./lifetime.test-helper.js"; +import { createLoop } from "./loop.js"; +import { unplaceable } from "./remote-systems.js"; +import { reportOf, runReported } from "./reports.test-helper.js"; + +const amount = Symbol("observerTest/amount"); +const cell = Symbol("observerTest/cell"); +const level = Symbol("observerTest/level"); +declare module "@adobe/ecs" { + interface Components { + [amount]: { type: number }; + [cell]: { type: number }; + } + interface Resources { + [level]: { type: number }; + } +} +Component.register(amount, { type: { type: "number" } }); +Component.register(cell, { type: { type: "number" } }); +Resource.register(level, { type: { type: "number", default: 0 } }); + +const byCell = Index.create({ name: "observerTest/byCell", key: [cell] }); + +/** What observer bodies saw, by system, reset by each test. */ +const seen: { levels: number[]; amounts: number[]; counted: number[]; cells: number[]; reports: number[]; removals: number[]; contexts: object[] } = { levels: [], amounts: [], counted: [], cells: [], reports: [], removals: [], contexts: [] }; +const reset = (): void => { + for (const list of Object.values(seen)) list.length = 0; +}; + +const raise = System.create({ name: "observerTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const spawn = System.create({ name: "observerTest/spawn" }, ({ commands }) => { commands.spawn([amount, cell])({ [amount]: 1, [cell]: 7 }); }); +const grow = System.create({ name: "observerTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); +const nothing = System.create({ name: "observerTest/nothing" }, () => {}); + +const watchLevel = System.create({ name: "observerTest/watchLevel", observer: true, resources: { read: [level] } }, context => { + seen.levels.push(context.resources[level]); + seen.contexts.push(context); +}); +const watchAmounts = System.create({ name: "observerTest/watchAmounts", observer: true, components: { read: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); +const count = System.create({ name: "observerTest/count", observer: true, components: { has: [amount] } }, () => { seen.counted.push(1); }); +const inCell = System.create({ name: "observerTest/inCell", observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { + seen.cells.push(lookup.found(7).length); +}); +const failing = System.create({ name: "observerTest/failing", observer: true, resources: { read: [level] } }, ({ resources }) => { + if (resources[level] > 0) throw new Error("observer failed"); +}); +const watchReport = System.create({ name: "observerTest/watchReport", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + seen.reports.push(row[Report.errors].failures.length); +}); +const raiseThenWatch = System.create([raise, watchLevel]); +const watchLevelChanges = System.create({ name: "observerTest/watchLevelChanges", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { + seen.levels.push(resources[level]); +}); + +/** The world a removing body acts on. */ +let current: World | null = null; +const removeLevel = System.create({ name: "observerTest/removeLevel", observer: true }, () => { + seen.removals.push(1); + current?.remove(watchLevel); +}); + +/** Adds `watchLevel` to the world the test runs, during a run. */ +const addWatch = System.create({ name: "observerTest/addWatch" }, () => { current?.add(watchLevel); }); +const addLevel = System.create({ name: "observerTest/addLevel", observer: true }, () => { + current?.add(watchLevel); +}); + +const inline = (): ReturnType => createWorld({ [workers]: 0 }); + +/** A loop platform that counts the beats a world asks for, running none. */ +const counting = () => { + const asked = { frames: 0, timers: 0 }; + return { asked, loop: { frame: () => { asked.frames++; return () => {}; }, timer: () => { asked.timers++; return () => {}; } } }; +}; + +describe("observer systems", () => { + it("run once after an empty sync point when run alone, with no commands in their context", async () => { + reset(); + await using world = inline(); + await world.run(raise); + expect(seen.levels).toEqual([]); + const ran = await world.run(watchLevel); + expect(seen.levels).toEqual([1]); + expect(seen.contexts.map(context => "commands" in context)).toEqual([false]); + expect(await reportOf(world, ran)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + }); + + it("run after every run they're added to, reading what its sync point left, each once", async () => { + reset(); + await using world = inline(); + world.add(count); + world.add(watchLevel); + world.add(watchLevel); + await world.run(spawn); + expect(seen.counted).toEqual([1]); + await world.run(raise); + expect([seen.counted, seen.levels]).toEqual([[1, 1], [0, 1]]); + // Added and run: once. + await world.run(watchLevel); + expect(seen.levels).toEqual([0, 1, 1]); + // A composite's observer members run after its sync point, with those added, each once. + await world.run(raiseThenWatch); + expect(seen.levels).toEqual([0, 1, 1, 2]); + }); + + it("see changes once, by their change filters, and index lookups refreshed for what the sync point moved", async () => { + reset(); + await using world = inline(); + world.add(watchAmounts); + world.add(inCell); + await world.run(spawn); + expect([seen.amounts, seen.cells]).toEqual([[1], [1]]); + await world.run(nothing); + expect(seen.amounts).toEqual([1]); + await world.run(grow); + await world.run(spawn); + expect([seen.amounts, seen.cells]).toEqual([[1, 2, 1], [1, 1, 1, 2]]); + }); + + it("report what they throw as system failures in the next run's report, and every other still runs", async () => { + reset(); + await using world = inline(); + world.add(failing); + world.add(watchLevel); + const clean = await world.run(nothing); + const raised = await world.run(raise); + expect(seen.levels).toEqual([0, 1]); + expect((await reportOf(world, clean)).failures).toEqual([]); + expect((await reportOf(world, raised)).failures).toEqual([]); + // Read by a run of its own, the next one, whose report holds them. + const { failures } = await reportOf(world, raised + 2); + expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "observerTest/failing", "observer failed"]]); + }); + + it("don't make the loop tick, and runs of them alone don't wake it", async () => { + const { asked, loop } = counting(); + await using world = createWorld({ [workers]: 0 }, { loop }); + world.add(watchLevel); + await world.run(raise); + expect(asked).toEqual({ frames: 0, timers: 0 }); + world.add(nothing); + await world.run(watchLevel); + expect(asked).toEqual({ frames: 0, timers: 0 }); + await world.run(raise); + expect(asked.frames).toBe(1); + }); + + it("run after each tick, their failures in the next run's report, which that run's pass sees", async () => { + reset(); + await using world = inline(); + world.add(raise); + world.add(failing); + world.add(watchReport); + world.add(watchLevel); + const ran = await world.run(nothing); + while (!seen.levels.includes(1)) await new Promise(resolve => setTimeout(resolve, 1)); + // The tick's pass threw after its report was written, which holds none. + expect(seen.reports).toEqual([]); + // The next run, of observer systems alone, which ticks nothing, has them in its report, which its pass sees. + const counted = await world.run(count); + expect(counted).toBe(ran + 2); + expect(seen.reports).toEqual([1]); + expect((await reportOf(world, ran + 1)).failures).toEqual([]); + expect((await reportOf(world, counted)).failures.map(({ name }) => name)).toEqual(["observerTest/failing"]); + }); + + it("leave the composite ticks run as it is when one added alone is removed, and release what it held", async () => { + const ticked = requireDefinition(nothing); + const watched = requireDefinition(watchLevel); + const loop = createLoop({ settings: () => ({ cadence: { kind: "frame" }, always: false, wake: Infinity }), tick: async () => {}, now: () => 0, frame: () => () => {}, timer: () => () => {} }); + loop.add(ticked); + const composite = loop.current(); + loop.add(watched); + expect([loop.current(), loop.observer()]).toEqual([composite, [watched]]); + expect(loop.remove(watched)).toEqual({ previous: composite, next: composite }); + expect([loop.current(), loop.observer()]).toEqual([composite, []]); + // A composite of observer systems alone doesn't tick. + loop.remove(ticked); + loop.add(requireDefinition(System.create([watchLevel, count]))); + expect([loop.current(), loop.observer().length]).toEqual([null, 2]); + + reset(); + const world = inline(); + await world.run(raise); + await world.run(nothing); + const before = await tablesAfterCollecting(world); + world.add(watchLevel); + await world.run(nothing); + expect((await world.tables()).runners).toBe(before.runners + 1); + world.remove(watchLevel); + await world.run(raise); + expect(seen.levels).toEqual([1]); + expect((await tablesAfterCollecting(world)).runners).toBe(before.runners); + await world.dispose(); + }); + + it("register one added alone before the next run, unless it's removed first", async () => { + const world = inline(); + await world.run(nothing); + const before = await tablesAfterCollecting(world); + world.add(count); + world.remove(count); + await world.run(nothing); + expect((await tablesAfterCollecting(world)).runners).toBe(before.runners); + await world.dispose(); + }); + + it("stay while added alone and within an added composite until both are removed", async () => { + reset(); + await using world = inline(); + const pair = System.create([nothing, watchLevel]); + world.add(pair); + world.add(watchLevel); + await world.run(raise); + world.remove(pair); + await world.run(raise); + world.add(pair); + world.remove(watchLevel); + await world.run(raise); + world.remove(pair); + await world.run(raise); + expect(seen.levels).toEqual([1, 2, 3]); + }); + + it("run from the first pass after they register, when added while a run that passes before it is queued", async () => { + reset(); + await using world = inline(); + current = world; + // Added during the first run's pass, so the second, queued already, passes before it registers. + const adding = world.run(addLevel); + const queued = world.run(raise); + await Promise.all([adding, queued]); + await world.run(raise); + current = null; + expect(seen.levels).toEqual([2]); + }); + + it("skip one an earlier body removed in the same pass", async () => { + reset(); + await using world = inline(); + current = world; + world.add(removeLevel); + world.add(watchLevel); + await world.run(raise); + await world.run(raise); + current = null; + expect([seen.removals, seen.levels]).toEqual([[1, 1], []]); + }); + + it("run after the next run once added during a run, whose own pass couldn't run it yet", async () => { + reset(); + await using world = inline(); + current = world; + await world.run(addWatch); + current = null; + await world.run(raise); + expect(seen.levels).toEqual([1]); + }); + + it("pass their change filters on the first pass once added again, as when first added", async () => { + reset(); + await using world = inline(); + await world.run(raise); + world.add(watchLevelChanges); + world.add(watchAmounts); + await world.run(spawn); + await world.run(nothing); + expect([seen.levels, seen.amounts]).toEqual([[1], [1]]); + world.remove(watchLevelChanges); + world.remove(watchAmounts); + await world.run(nothing); + world.add(watchLevelChanges); + world.add(watchAmounts); + await world.run(nothing); + await world.run(nothing); + expect([seen.levels, seen.amounts]).toEqual([[1, 1], [1, 1]]); + }); + + it("run from the next pass once added again after the run that removed them, even for a run queued before", async () => { + reset(); + await using world = inline(); + world.add(watchLevel); + await world.run(nothing); + world.remove(watchLevel); + await world.run(nothing); + const queued = world.run(raise); + world.add(watchLevel); + await queued; + expect(seen.levels).toEqual([0, 1]); + }); + + it("can be placed on a joined thread", () => { + const leaf = requireDefinition(watchLevel); + expect(isObserver(leaf) ? unplaceable(leaf, "main") : "not observer").toBe(null); + }); +}); diff --git a/packages/ecs/src/implementation/world/observer.ts b/packages/ecs/src/implementation/world/observer.ts new file mode 100644 index 000000000..815763613 --- /dev/null +++ b/packages/ecs/src/implementation/world/observer.ts @@ -0,0 +1,139 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import type { Failure } from '../../interface/report/failure.js'; +import { labelOfItem } from '../../interface/registry/index.js'; +import { readAhead, type ExecuteContext } from './execute.js'; +import { failureOf } from './failures.js'; +import { refreshUses } from './indexes.js'; +import type { Runner } from './runner.js'; +import { joinedOf, type JoinedObserver } from './joined-observer.js'; + +/** + * Runs observer systems after a sync point, on this thread: those of the run, `own`, then those added, `added`, each + * once. What the GPU wrote that they read, or their indexes hold by, is read back, then those indexes are refreshed; + * then each runs, unless it was removed since, even by a body earlier in the pass. Returns `failures` with theirs, as + * `"system"` failures, and the index conflicts refreshing found: a promise only when refreshing or reading back waited. + */ +export const runObserver = (context: ExecuteContext, own: readonly Leaf[], added: readonly Leaf[], failures: Failure[] | null): Failure[] | null | Promise => { + const ownPass = passOf(context, own, false); + const addedPass = passOf(context, added, true); + // Those on joined threads are posted there, which reads back and refreshes what they read itself. + if (!ownPass.here && !addedPass.here) return context.state.indexFailures.length === 0 ? failures : runAll(context, own, added, ownPass, addedPass, failures); + const waiting = ownPass.indexed || addedPass.indexed || context.state.gpu !== null ? prepare(context, ownPass, addedPass) : null; + if (waiting !== null) return finishAfter(waiting, context, own, added, ownPass, addedPass, failures); + return runAll(context, own, added, ownPass, addedPass, failures); +}; + +/** + * A list's runners, read again once one was replaced or removed, or for another list; whether any looks up through an + * index, and whether any runs on this thread rather than a joined one. A runner removed since it was read reads as + * removed, so a pass in progress skips it. + */ +type Pass = { leaves: readonly Leaf[]; readonly runners: Runner[]; indexed: boolean; here: boolean; version: number; joined: readonly JoinedObserver[] | null }; + +/** + * A context's passes: by list for runs' own observer systems, whose lists last as their schedules do; and one for those + * added, whose list is new whenever one is added or removed. + */ +type Passes = { readonly own: WeakMap; readonly added: Pass }; + +const passes = new WeakMap(); +const noLeaves: readonly Leaf[] = []; +const emptyPass: Pass = { leaves: noLeaves, runners: [], indexed: false, here: false, version: -1, joined: [] }; +const createPass = (leaves: readonly Leaf[]): Pass => ({ leaves, runners: [], indexed: false, here: false, version: -1, joined: null }); + +const passOf = (context: ExecuteContext, leaves: readonly Leaf[], added: boolean): Pass => { + if (leaves.length === 0) return emptyPass; + let all = passes.get(context); + if (all === undefined) passes.set(context, all = { own: new WeakMap(), added: createPass(noLeaves) }); + let pass = added ? all.added : all.own.get(leaves); + if (pass === undefined) all.own.set(leaves, pass = createPass(leaves)); + const { runnersVersion } = context.state; + if (pass.version !== runnersVersion || pass.leaves !== leaves) { + const { runners } = pass; + runners.length = leaves.length; + pass.leaves = leaves; + pass.indexed = false; + pass.here = false; + pass.joined = null; + for (let k = 0; k < leaves.length; k++) { + const runner = runners[k] = context.runnerOf(leaves[k]); + if (runner.indexes.length > 0) pass.indexed = true; + if (runner.joined === null) pass.here = true; + } + pass.version = runnersVersion; + } + return pass; +}; + +/** Refreshes the indexes of a pass's runners, adding what to wait for to `waiting`. */ +const refreshIndexes = (context: ExecuteContext, pass: Pass, waiting: Promise[] | null): Promise[] | null => { + if (!pass.indexed) return waiting; + let pending = waiting; + for (const { indexes } of pass.runners) { + if (indexes.length === 0) continue; + const building = refreshUses(context.state, indexes, context.workers?.pool ?? null); + if (building !== null) (pending ??= []).push(building); + } + return pending; +}; + +/** + * Refreshes the indexes the systems look up through, and reads back what the GPU wrote that they read; returns what to + * wait for, or null when nothing waits. + */ +const prepare = (context: ExecuteContext, own: Pass, added: Pass): Promise | null => { + const { gpu } = context.state; + // What the GPU wrote is read back first, so the indexes refresh from the values it wrote. + if (gpu !== null && gpu.anyAhead()) return readAhead(context.state, gpu, [...own.runners, ...added.runners]).then(() => refreshAll(context, own, added) ?? undefined); + return refreshAll(context, own, added); +}; + +const refreshAll = (context: ExecuteContext, own: Pass, added: Pass): Promise | null => { + const waiting = refreshIndexes(context, added, refreshIndexes(context, own, null)); + return waiting === null ? null : Promise.all(waiting).then(() => undefined); +}; + +const finishAfter = async (waiting: Promise, context: ExecuteContext, own: readonly Leaf[], added: readonly Leaf[], ownPass: Pass, addedPass: Pass, failures: Failure[] | null): Promise => { + await waiting; + return runAll(context, own, added, ownPass, addedPass, failures); +}; + +const runAll = (context: ExecuteContext, own: readonly Leaf[], added: readonly Leaf[], ownPass: Pass, addedPass: Pass, failures: Failure[] | null): Failure[] | null => { + const { state } = context; + let failed = failures; + if (state.indexFailures.length !== 0) (failed ??= []).push(...state.indexFailures.splice(0)); + for (let k = 0; k < own.length; k++) failed = runOne(own[k], ownPass.runners[k], failed); + const { runners } = addedPass; + for (let k = 0; k < added.length; k++) { + // One the run holds too ran with the run's. + if (own.length === 0 || !own.includes(added[k])) failed = runOne(added[k], runners[k], failed); + } + return failed; +}; + +/** + * The observer systems of a list placed on joined threads, which run there once posted (`joined-observer.ts`): a run's + * own, or with `added`, those added. + */ +export const joinedIn = (context: ExecuteContext, leaves: readonly Leaf[], added: boolean): readonly JoinedObserver[] => { + const pass = passOf(context, leaves, added); + if (pass.joined === null) pass.joined = joinedOf(pass.runners); + return pass.joined; +}; + +/** + * Runs one observer system, adding its failure to `failures`. Its context has no commands, so its chunks hold none. One + * on a joined thread runs there. + */ +const runOne = (leaf: Leaf, runner: Runner, failures: Failure[] | null): Failure[] | null => { + if (runner.holdings.removed || runner.joined !== null) return failures; + const chunks = runner.start(undefined, null, -1); + for (let c = 0; c < chunks.length; c++) { + const chunk = chunks[c]; + // An observer system runs here, as no kernel, so its chunks are settled. + if (chunk instanceof Promise || chunk.error === undefined) continue; + (failures ??= []).push(failureOf("system", labelOfItem(leaf), chunk.error)); + break; + } + return failures; +}; diff --git a/packages/ecs/src/implementation/world/order.ts b/packages/ecs/src/implementation/world/order.ts new file mode 100644 index 000000000..a0297e390 --- /dev/null +++ b/packages/ecs/src/implementation/world/order.ts @@ -0,0 +1,351 @@ +import { allocateArray, freeArray } from './arrays.js'; +import type { Memory } from './memory.js'; + +/** + * An `up` system's rows in run order: groups by depth, then archetype, `matches[groupOwners[k]]`, then parents' + * archetype, `groupParents[k]`, or -1 without a parent with every component `up` reads. Group `k` is slots + * `groupStarts[k]` to `groupEnds[k] - 1`; slot `n` is row `rows[n]` of its archetype, entity index `entities[n]`, + * whose parent is row `parents[n]`. Within a group, rows are in storage order. Depth `d`'s groups are + * `depthGroups[d]` to `depthGroups[d + 1] - 1`. Slots between groups are unused, up to `size`. + */ +export type Order = { + readonly rows: Int32Array; + readonly parents: Int32Array; + readonly entities: Int32Array; + readonly size: number; + readonly groups: number; + readonly groupStarts: Int32Array; + readonly groupEnds: Int32Array; + readonly groupDepths: Int32Array; + readonly groupOwners: Int32Array; + readonly groupParents: Int32Array; + readonly depthGroups: Int32Array; + readonly levels: number; +}; + +/** Spare slots a group gets when laid out: an eighth of its rows, at least this many. */ +const slack = 8; + +/** Grows an array to at least `length`, keeping its contents and filling the rest with `value`. */ +const extend = (array: Int32Array, length: number, value: number): Int32Array => { + if (array.length >= length) return array; + const grown = new Int32Array(Math.max(length, 2 * array.length)); + grown.set(array); + grown.fill(value, array.length); + return grown; +}; + +/** + * The slots of an `up` order, kept in segments of one buffer, one per group, each with spare slots, so a change + * patches one group: a row moves within its group by binary search and a shift. Rows and parents' rows are in the + * arena, so WASM kernels and the GPU can address them. + */ +export const createOrder = (memory: Memory) => { + let rows: Int32Array = new Int32Array(0); + let parents: Int32Array = new Int32Array(0); + let entities = new Int32Array(0); + let size = 0; + let live = 0; + /** By group id: its first slot, rows, spare slots included, depth, archetype and parents' archetype. */ + let starts = new Int32Array(0); + let lengths = new Int32Array(0); + let capacities = new Int32Array(0); + let depths = new Int32Array(0); + let owners = new Int32Array(0); + let parentKeys = new Int32Array(0); + let groups = 0; + /** Group ids in run order. */ + let sorted = new Int32Array(0); + const byKey = new Map(); + /** By entity index: its group, or -1, and its row there. */ + let groupOf = new Int32Array(0); + let rowOf = new Int32Array(0); + let view: Order | null = null; + + /** Grows the slot arrays to `length`, keeping their contents. */ + const reserve = (length: number): void => { + if (rows.length >= length) return; + const capacity = Math.max(length, 2 * rows.length); + const grownRows = allocateArray(memory, Int32Array, capacity); + const grownParents = allocateArray(memory, Int32Array, capacity); + grownRows.set(rows.subarray(0, size)); + grownParents.set(parents.subarray(0, size)); + if (rows.length > 0) { + freeArray(memory, rows); + freeArray(memory, parents); + } + rows = grownRows; + parents = grownParents; + entities = extend(entities, capacity, 0); + }; + + const growGroups = (): void => { + if (starts.length > groups) return; + const capacity = Math.max(16, 2 * starts.length); + starts = extend(starts, capacity, 0); + lengths = extend(lengths, capacity, 0); + capacities = extend(capacities, capacity, 0); + depths = extend(depths, capacity, 0); + owners = extend(owners, capacity, 0); + parentKeys = extend(parentKeys, capacity, 0); + sorted = extend(sorted, capacity, 0); + }; + + const keyOf = (depth: number, owner: number, parent: number): string => `${depth} ${owner} ${parent}`; + + /** Whether group `a` runs before group `b`. */ + const before = (a: number, b: number): boolean => + depths[a] !== depths[b] ? depths[a] < depths[b] : owners[a] !== owners[b] ? owners[a] < owners[b] : parentKeys[a] < parentKeys[b]; + + /** A new, empty group with `capacity` slots at the end of the buffer, placed in run order. */ + const addGroup = (depth: number, owner: number, parent: number, capacity: number): number => { + growGroups(); + const id = groups++; + depths[id] = depth; + owners[id] = owner; + parentKeys[id] = parent; + lengths[id] = 0; + reserve(size + capacity); + starts[id] = size; + capacities[id] = capacity; + size += capacity; + let at = id; + while (at > 0 && before(id, sorted[at - 1])) { + sorted[at] = sorted[at - 1]; + at--; + } + sorted[at] = id; + byKey.set(keyOf(depth, owner, parent), id); + return id; + }; + + /** Moves a full group to the end of the buffer with twice the slots. */ + const relocate = (id: number): void => { + const capacity = 2 * capacities[id] + slack; + reserve(size + capacity); + const from = starts[id]; + rows.copyWithin(size, from, from + lengths[id]); + parents.copyWithin(size, from, from + lengths[id]); + entities.copyWithin(size, from, from + lengths[id]); + starts[id] = size; + capacities[id] = capacity; + size += capacity; + }; + + /** The slot of `index` in its group, found by its row. */ + const slotOf = (id: number, index: number): number => { + const row = rowOf[index]; + let low = starts[id]; + let high = low + lengths[id]; + while (low < high) { + const middle = (low + high) >>> 1; + if (rows[middle] < row) low = middle + 1; + else high = middle; + } + // Rows are unique within a group once removals are done; an insertion pending may share one meanwhile. + while (entities[low] !== index) low++; + return low; + }; + + const grow = (length: number): void => { + groupOf = extend(groupOf, length, -1); + rowOf = extend(rowOf, length, 0); + }; + + /** Removes an entity's slot, if it has one. */ + const remove = (index: number): void => { + const id = index < groupOf.length ? groupOf[index] : -1; + if (id < 0) return; + const slot = slotOf(id, index); + const end = starts[id] + lengths[id]; + rows.copyWithin(slot, slot + 1, end); + parents.copyWithin(slot, slot + 1, end); + entities.copyWithin(slot, slot + 1, end); + lengths[id]--; + live--; + groupOf[index] = -1; + view = null; + }; + + /** Places an entity's row in its group, in storage order. */ + const insert = (index: number, depth: number, owner: number, parent: number, row: number, parentRow: number): void => { + grow(index + 1); + const id = byKey.get(keyOf(depth, owner, parent)) ?? addGroup(depth, owner, parent, slack); + if (lengths[id] === capacities[id]) relocate(id); + const start = starts[id]; + const end = start + lengths[id]; + let low = start; + let high = end; + while (low < high) { + const middle = (low + high) >>> 1; + if (rows[middle] < row) low = middle + 1; + else high = middle; + } + rows.copyWithin(low + 1, low, end); + parents.copyWithin(low + 1, low, end); + entities.copyWithin(low + 1, low, end); + rows[low] = row; + parents[low] = parentRow; + entities[low] = index; + lengths[id]++; + live++; + groupOf[index] = id; + rowOf[index] = row; + view = null; + }; + + /** Whether an entity's slot is in the group of this depth, archetype and parents' archetype, at this row. */ + const isAt = (index: number, depth: number, owner: number, parent: number, row: number): boolean => { + const id = index < groupOf.length ? groupOf[index] : -1; + return id >= 0 && rowOf[index] === row && depths[id] === depth && owners[id] === owner && parentKeys[id] === parent; + }; + + /** Sets an entity's parent's row, in place. */ + const setParent = (index: number, parentRow: number): void => { + const id = groupOf[index]; + parents[slotOf(id, index)] = parentRow; + }; + + /** Lays the groups out again in run order, each with spare slots, dropping empty ones. */ + const compact = (): void => { + const kept: number[] = []; + let total = 0; + for (let k = 0; k < groups; k++) { + const id = sorted[k]; + if (lengths[id] === 0) continue; + kept.push(id); + total += lengths[id] + spareFor(lengths[id]); + } + const toRows = allocateArray(memory, Int32Array, Math.max(total, 1)); + const toParents = allocateArray(memory, Int32Array, Math.max(total, 1)); + const toEntities = new Int32Array(Math.max(total, 1)); + const toStarts = new Int32Array(Math.max(16, kept.length)); + const toLengths = new Int32Array(toStarts.length); + const toCapacities = new Int32Array(toStarts.length); + const toDepths = new Int32Array(toStarts.length); + const toOwners = new Int32Array(toStarts.length); + const toParentKeys = new Int32Array(toStarts.length); + byKey.clear(); + let at = 0; + kept.forEach((id, k) => { + const from = starts[id]; + const length = lengths[id]; + toRows.set(rows.subarray(from, from + length), at); + toParents.set(parents.subarray(from, from + length), at); + toEntities.set(entities.subarray(from, from + length), at); + for (let slot = at; slot < at + length; slot++) groupOf[toEntities[slot]] = k; + toStarts[k] = at; + toLengths[k] = length; + toCapacities[k] = length + spareFor(length); + toDepths[k] = depths[id]; + toOwners[k] = owners[id]; + toParentKeys[k] = parentKeys[id]; + byKey.set(keyOf(depths[id], owners[id], parentKeys[id]), k); + at += toCapacities[k]; + }); + if (rows.length > 0) { + freeArray(memory, rows); + freeArray(memory, parents); + } + rows = toRows; + parents = toParents; + entities = toEntities; + starts = toStarts; + lengths = toLengths; + capacities = toCapacities; + depths = toDepths; + owners = toOwners; + parentKeys = toParentKeys; + groups = kept.length; + sorted = new Int32Array(starts.length); + for (let k = 0; k < groups; k++) sorted[k] = k; + size = at; + view = null; + }; + + /** Spare slots for a group of `length` rows. */ + const spareFor = (length: number): number => Math.max(slack, length >> 3); + + /** + * Replaces every slot with groups laid out in run order, each with spare slots: slots `ends[k - 1]` (or 0) to + * `ends[k] - 1` of the given arrays are group `k`, of `groupDepths[k]`, `groupOwners[k]` and `groupParents[k]`. + */ + const load = (count: number, groupRows: Int32Array, groupParentRows: Int32Array, groupEntities: Int32Array, ends: Int32Array, groupDepths: Int32Array, groupOwners: Int32Array, groupParents: Int32Array, entityCount: number): void => { + const total = count === 0 ? 0 : ends[count - 1]; + groups = 0; + size = 0; + byKey.clear(); + reserve(total + count * slack + (total >> 3)); + grow(entityCount); + groupOf.fill(-1); + const toRows = rows, toParents = parents, toEntities = entities, entityGroups = groupOf, entityRows = rowOf; + for (let k = 0; k < count; k++) { + growGroups(); + const id = groups++; + const from = k === 0 ? 0 : ends[k - 1]; + const length = ends[k] - from; + starts[id] = size; + lengths[id] = length; + capacities[id] = length + spareFor(length); + depths[id] = groupDepths[k]; + owners[id] = groupOwners[k]; + parentKeys[id] = groupParents[k]; + sorted[id] = id; + byKey.set(keyOf(groupDepths[k], groupOwners[k], groupParents[k]), id); + toRows.set(groupRows.subarray(from, ends[k]), size); + toParents.set(groupParentRows.subarray(from, ends[k]), size); + toEntities.set(groupEntities.subarray(from, ends[k]), size); + for (let n = from; n < ends[k]; n++) { + entityGroups[groupEntities[n]] = id; + entityRows[groupEntities[n]] = groupRows[n]; + } + size += capacities[id]; + } + live = total; + view = null; + }; + + /** Compacts once unused slots outnumber the rows. */ + const tidy = (): void => { + if (size > 2 * live + 1024) compact(); + }; + + /** The order as runs read it; a new object after any change, so the GPU knows to upload it again. */ + const current = (): Order => { + if (view !== null) return view; + const groupStarts = new Int32Array(groups); + const groupEnds = new Int32Array(groups); + const groupDepths = new Int32Array(groups); + const groupOwners = new Int32Array(groups); + const groupParents = new Int32Array(groups); + let levels = 0; + for (let k = 0; k < groups; k++) { + const id = sorted[k]; + groupStarts[k] = starts[id]; + groupEnds[k] = starts[id] + lengths[id]; + groupDepths[k] = depths[id]; + groupOwners[k] = owners[id]; + groupParents[k] = parentKeys[id]; + levels = depths[id] + 1; + } + const depthGroups = new Int32Array(levels + 1); + for (let k = 0, depth = 0; depth <= levels; depth++) { + while (k < groups && groupDepths[k] < depth) k++; + depthGroups[depth] = k; + } + view = { rows, parents, entities, size, groups, groupStarts, groupEnds, groupDepths, groupOwners, groupParents, depthGroups, levels }; + return view; + }; + + /** Frees the slot arrays in the arena, once nothing runs the order again. */ + const release = (): void => { + if (rows.length === 0) return; + freeArray(memory, rows); + freeArray(memory, parents); + rows = parents = new Int32Array(0); + size = live = groups = 0; + view = null; + }; + + return { load, insert, remove, isAt, setParent, tidy, current, live: () => live, release }; +}; diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md new file mode 100644 index 000000000..ac28b9a16 --- /dev/null +++ b/packages/ecs/src/implementation/world/perf.md @@ -0,0 +1,356 @@ +# Performance + +Measured on an Apple M1 Max (8 performance cores, 2 efficiency cores). Node numbers come from `pnpm perf`, a Vite bundle run on plain node; vitest's node transform turns imported bindings into getters and roughly doubles JS-heavy costs, so it is not used for comparisons. Browser numbers come from `pnpm test:perf` in chromium with real workers, where timers are quantized to 5 µs; "framed" numbers run once per animation frame and report the median and 90th percentile, which vary up to 2× between sessions. Every number is the fastest run within a time budget. + +## Results + +Baseline is commit `a28ba8cb`, before the optimization pass. + +| Benchmark | Baseline | Now | Gain | +|---|---|---|---| +| create 100K (spawn plus sync point) | 170 ms | 27.6 ms | 6.2× | +| churn 100K (despawn and respawn 10%) | 17.0 ms | 5.05 ms | 3.4× | +| retag 100K (archetype moves for 20%) | 18.0 ms | 7.04 ms | 2.6× | +| emit 100K (commands from a kernel) | 6.65 ms | 0.97 ms | 6.9× | +| sparse changed 1M, uniform 1% | 3.48 ms | 1.15 ms | 3.0× | +| sparse changed 1M, clustered 1% | 3.13 ms | 0.021 ms | ~150× | +| follow 100K (a lookup per row) | 10.6 ms | 0.38 ms | 28× | +| move 1M (250K rows processed) | 0.259 ms | 0.095 ms | 2.7× | +| chain, 6 dependent kernels over 100K | 0.476 ms | 0.343 ms | 1.4× | +| main thread, 50 systems over 1K | 2.89 ms | 1.64 ms | 1.8× | +| select 100K (`where` plus reads) | 18.1 ms | 3.1–4.5 ms | ~5× | + +Chromium: + +| Benchmark | Main thread | Workers (default pool) | +|---|---|---| +| move 1M | 0.305 → 0.105 ms | 0.105 → 0.020 ms | +| follow 100K | 8.76 → 0.44 ms | 1.02 → 0.075 ms | +| chain 6×100K | 0.67 → 0.405 ms | 0.455 → 0.115 ms | +| sparse changed 1M | 4.78 → 1.095 ms | 0.74 → 0.19–0.25 ms | + +At 1M entities, against commit `d6373560` (claimed splits in place): + +| Benchmark | Before | Now | +|---|---|---| +| create 1M | 325 ms | 88 ms | +| create flat 1M (ecs2's test: 8 scalars per entity) | | 70 ms; 223 ms with value objects built first | +| churn 1M | 54 ms | 17 ms | +| retag 1M | 79 ms | 42 ms | +| emit 1M | 11.2 ms | 10.6 ms | +| move 1M | 0.177 ms | 0.091 ms | +| chain 6×1M | 3.42 ms | 1.51 ms | +| main thread 50×1K | 1.65 ms | 0.48 ms | +| select 1M | 37.5 ms | 24 ms | +| follow 1M, sparse changed 1M | | unchanged | +| chromium move 1M, main / workers | 0.20 / 0.030 ms | 0.095 / 0.015 ms | +| chromium chain 6×1M, main / workers | 4.47 / 0.725 ms | 1.72 / 0.275 ms | + +Moving the code into `src/interface` and `src/implementation`, with ticks added, measured within noise of the record before it (`f9e8d10a`) on every node and chromium benchmark: run idle 333 ns, run 50 idle 109 ns per system, sync point one spawn 1.04 µs, create 1M 91 ms, churn 19.5 ms, retag 35.7 ms, move 1M 0.090 ms, chain 1.54 ms, select 8.27 ms; in chromium, move on workers 0.015 ms and chain on workers 0.29 ms. A run checks the `workers` resource by its changed tick, allocating nothing, and makes the loop due only while systems are added. + +Components, resources and traits are keyed by symbols, and the benchmarks moved to symbol keys with them (record `d4256133`, against `4aa829f4` on string keys): every benchmark is within noise but two. `create flat dynamic`, which builds value objects first and spreads them, went 224 → 434 ns per spawn, since V8 lists an object's symbol keys without the enumeration cache `Object.keys` has for strings. `retag` went 35.8 → 33.6 ms, since its literal `commands.add(entity, { [tag]: true })` is now a prepared site; through value objects it measured 43.9 ms. `create`, whose spawns are prepared sites, stayed at 90–94 ms. + +**Key reading** (`perftest/keys.ts`, `node dist/perf/run.js --keys`): nanoseconds per values object built and read, for 1 to 6 keys. Listing dominates: reading the same values by keys known in advance costs 58–73 ns, mostly the reads themselves. + +| Method | 1 | 2 | 3 | 4 | 5 | 6 | +|---|---|---|---|---|---|---| +| `Object.getOwnPropertySymbols` (kept) | 98.7 | 119 | 141 | 168 | 190 | 214 | +| `Object.keys`, then `Object.getOwnPropertySymbols` | 106 | 123 | 145 | 175 | 196 | 217 | +| `Reflect.ownKeys`, symbols only | 104 | 124 | 146 | 171 | 195 | 219 | +| string keys with `Object.keys`, for comparison | 22.8 | 31.6 | 41.0 | 49.3 | 58.3 | 65.2 | + +So literal spawns and adds in main-thread systems are prepared sites, which list no keys and build no object; only values objects built at runtime pay for listing. + +Archetypes and commands that name their components first (record `bd0dabcc`, against the baseline `4e63c933`, which added a benchmark per kind of structural change): every benchmark is within noise or faster but `spawn bulk kernel` (120 → 131 ns, +9%, every component a kernel's list names now gets a value, its tag's start included) and `spawn singles` (458 → 490 ns, +7%). Faster: `create` 90.6 → 58.1 ns per spawn, `create flat dynamic` 434 → 93.7 ns (beating string keys' 224 ns), `spawn sixteen archetypes` 757 → 57.6 ns, `spawn bulk` 49.8 → 40.7 ns, `despawn bulk` 65.2 → 44.2 ns, `remove tag` 107 → 72.4 ns, `remove several` 152 → 79.2 ns, `add tag kernel` 104 → 85 ns, `add value` 109 → 96 ns; `create flat` 69.8 → 66.8 ns, `add tag` 107 → 100 ns, `add several` 140 → 142 ns, `retag` 35.6 → 33.9 ms. Chromium is unchanged. + +- **Spawners are generated per shape:** one read by key and one store per value, so each read sees one object shape. Against a loop over the shape's columns, which is what runs where `'unsafe-eval'` is forbidden: `create` 58 against 126 ns, `create flat` 66 against 127 ns, `create flat dynamic` 94 against 172 ns, `spawn bulk` 41 against 59 ns. +- **Literal spawns and adds stay prepared:** without the plugin's per-site functions, the values object costs about 25 ns for eight keys, so `create flat` measured 86 ns against 66 ns, and `add several` 169 against 142 ns. +- **Archetypes are found by their index:** with only the eight-entry cache, sixteen archetypes from one site resolved their list on every spawn, 523–561 ns; the per-world table by archetype index, and a site's plans by index, take it to 57.6 ns. +- **Moves are grouped by pair of archetypes,** copying shared columns for a block of rows, and rows left are removed last entry first, so an archetype that moves out whole drops its rows without copies; list adds and removes resolve their target once per source archetype. + +Sync point fixes for spawns (record `ebde4c9b`, against `bd0dabcc`): `spawn bulk kernel` 131 → 121 ns, back to the baseline's 120, and `spawn singles` 500 → 292 ns, below the baseline's 458; every other node and chromium benchmark within noise (`run idle` reads 333 or 375 ns from one record to the next, as timer steps; `bd0dabcc` measures 375 ns beside this one). Kernels reading lists from other modules changed only what the compiler emits. Each step, measured on its own: + +- **A pair whose entries are consecutive needs no ordering pass,** as a system's spawns or moves from one archetype are, and rows are removed only when some entry leaves one: `spawn bulk kernel` 130 → 127 ns. Reading entries through a branch per row instead of an identity order cost `retag` +3%, so the order is still written, in one sequential pass. +- **The plan grows once per buffer,** since each command adds at most one entry, rather than checking capacity at every entry: `spawn bulk kernel` 127 → 120 ns; `retag` 34.5 → 33.5 ms. +- **A spawn batch keeps its archetype,** rather than finding it by its joined ids at every sync point: `spawn singles` 489 → 396 ns. +- **A batch of at most eight rows commits element by element,** since a subarray to copy from costs more than the rows: `spawn singles` 396 → 292 ns. + +Kernel spawns staged as main-thread spawns are (record `2b07c576`, against `ebde4c9b`): `spawn bulk kernel` 121 → 10.3 ns, now below `spawn bulk` from a main-thread system, which went 40.4 → 25.4 ns with it; `spawn sixteen archetypes` 56.5 → 41.1 ns. In chromium, the new `spawn bulk kernel main` and `spawn bulk kernel workers`, measured on `ebde4c9b`'s implementation first: 128 → 11 ns on the main thread, 177 → 7.65 ns on workers. Every other node and chromium benchmark is within noise (`create` reads 58–62 ns from one run to the next on either side, and node `sparse changed` 0.32–0.37 ms, as in records since `c234c41a`). Each step, measured on its own: + +- **Kernel spawns are staged in batches,** through a spawner per spawn list generated as main-thread spawners are, rather than issued as commands the sync point plans and writes value by value: 120 → 34 ns; in chromium 128 → 18 ns on the main thread and 177 → 54 ns on workers. A main-thread runner's batches and each chunk's buffer naming the rows it staged keep the row order the same whichever thread ran each chunk. +- **The reused indexes a sync point reserved are copied as a typed array,** not an array of numbers: `spawn bulk kernel` 34 → 18 ns, `spawn bulk` 41 → 25 ns, `spawn sixteen archetypes` 58 → 41 ns. +- **Kernel spawners reserve entities in blocks,** so workers spawning together don't contend for one atomic cursor, and the sync point returns the positions a block left unused to the free list: on workers 54 → 7.5 ns, on the main thread 18 → 10.5 ns. +- **Runners stage only when their kernel spawns:** without the check, `run 50 idle` measured 120 against 110–112 ns per system. + +The shared layout directory (threads.md, phase 1), against `1a18c6a7`: workers find archetypes and the entity table through records in shared memory, so a sync point that grows archetypes posts no message and leaves workers' windows open. In chromium, `sync point grow 100 archetypes workers` (one spawn into each of 100 full archetypes, then a kernel over them) 1.43 → 0.62–0.68 ms and `spawn bulk kernel workers` 7.7–7.95 → 7.15–7.5 ns; `sync point grow 100 archetypes main`, `loop tick idle` and every node benchmark are within noise. + +Command rings for the pool (threads.md, phase 3), against `efedcd21`: worker kernels write their commands into a ring in shared memory as the columns of a `CommandBuffer`, and the world drains a segment by taking views of them, its structs' values staying bytes in the ring, rather than receiving buffers by message. The new `emit workers`, alternating against `efedcd21`'s message path: in node 6.35–6.48 against 6.94–7.03 ms, in chromium 7.76–7.8 against 8.13–8.22 ms. In chromium the run splits into the tiers, 1.15 against 1.9 ms, and the sync point, 6.05 against 5.85 ms: planning reads the columns where workers wrote them, 2.63 against 2.36 ms for freshly deserialized ones, about 3 ns more per command. `emit main` and every must-not-regress benchmark are within noise of `efedcd21` on both. + +The adobe/data ECS runs move 1M in 0.09 ms, single threaded with WASM SIMD, and creates 100K in 5.8 ms by inserting directly, with no command buffer or sync point. + +Observer systems (P1 of the observers plan), alternating against `af4c72f4`: every node benchmark within noise but `run 50 idle` 110 → 73 ns a system, from three changes to a runner's start that every run takes; `run idle` 416 → 375–416 ns, `loop tick idle` 1.84 → 1.81 µs, `read run` 10.9 → 10.5 µs. New: `run observer` 208 ns against 375–416 for `run idle`, `read run observer` 7.9–9.3 µs against 10.5–11.8 for `read run`, `observer idle 100` 81–85 ns an observer system a tick, about 19 of it the tick's own cost shared over a hundred. Each step, measured on its own: + +- **A run's chunks get their length set only when it differs:** writing an array's length is a runtime call even when it's unchanged: `observer idle 100` 122 → 94 ns, `run 50 idle` 112 → 88 ns. +- **Conditions are checked only by a runner that has some:** `observer idle 100` 94 → 86 ns, `run 50 idle` 90 → 80 ns. +- **A run starts on the runner's first buffer and chunk directly,** with nothing staged yet to stage: `observer idle 100` 86 → 78–83 ns, `run 50 idle` 80 → 73 ns. +- **Each list of observer systems keeps its runners,** read again only once runners change, with whether any looks up through an index, rather than finding each runner by its system twice a pass: `observer idle 100` 144 → 122 ns. + +Observer systems on joined threads (P3 of the observers plan), alternating against `ffea07de`: every node benchmark touched within noise (`run idle`, `run 50 idle`, `run observer`, `observer idle 100`, `loop tick idle`, `sync point one spawn`, `remote run`, `handoff`, `fused tiers`, `tail prompt`, `read run`, `read run observer`, `loop tick add remove`, `create`, `follow`), and in chromium `loop tick idle` and `remote run`. New: `joined observer resource` 6.4–6.8 µs a tick in node, 9.3 in chromium; `joined observer 10K` 0.30 ms in node, 0.28 in chromium. Back to back, the world outruns the thread's wake-up, so most posts are cancelled before the thread claims them and the 10K sort runs about one tick in five there; the world's cost is the sync points and writers waiting for posts in progress, and the waited-for post after four cancelled ones. +- **A run of observer systems alone skips its sync point** while nothing is queued and there's no GPU, joined thread or tail: `run observer` 375 → 208 ns, `read run observer` 8.8 → 7.7 µs. + +Observer factories, alternating against `799a0e1a`, every other node benchmark touched within noise (`run idle`, `run 50 idle`, `run observer`, `observer idle 100`, `loop tick idle`, `sync point one spawn`, `follow`, `create`, `remote run`, `joined observer resource`, `read run`, `read run observer`, `loop tick add remove`), and in chromium `loop tick idle`, `remote run` and `observe gpu entity factory`: +- **An observer system added again registers at once with the runner it had,** restarted from tick 0, once the run that removed it is done: `read observe factory cold` 5.8 µs (14.1 at the old 300 rounds) → 0.88 µs. What's left is the reader's run with the maintenance system and the notifier in its pass, the notifier's add, and the promise and flush around them. +- **Runs read resources without allocating:** a context binds each resource's storage and the resource entity's row, `changed` filters on resources read stamps by id, and the device check reads its cell directly. With one pass kept for the list of added observer systems rather than one per list (each add or remove makes a new list), a cold read 958 → 833 ns. +- **A scan of one entry with at most one test, every entity or a count, gathers in the scanner's body,** without a call per row through an acceptance and a gather, and each scan's value is made at its length rather than grown: `select factory` 10.2 → 7.5 ms. An observer row system testing and writing a typed array alone takes 2.0 ms over the same 1M rows: the gap is the row body's calls. +- `observe 1K entities one changed factory` (29.5 µs) and chromium `observe gpu entity factory` (450–468 µs) cost each entity's lookup by entity, and entity observers are mainly for debugging. + +Observer factories on a joined thread's world (C2 of the observers plan), and the fixes to observer posts it found, alternating against `320260b0`, node; every touched benchmark within noise but `joined observer 10K` (`joined observer resource` 6.8–8.4 µs a tick there against 7.2–7.6, `remote run`): +- **New:** `joined resource factory` 6.5–7.8 µs a tick back to back, about a plain observer system's (`joined observer resource`); at a display's cadence, frames 2 ms apart, `joined resource factory cadence` 21–32 µs of the world's thread a tick, a plain observer system's the same: what's left is posting an observer system there (the writer's copy of what a post reads, settling posts at the sync point, waking the thread). `joined select 10K factory` 0.51–0.55 ms a tick: an unpaged ordered selection sorts on the joined thread through an index without a key, where the sorted index the world's thread keeps for one cost 0.74–0.95 ms. In chromium, three rounds: `joined resource factory` 9.4–11.3 µs (`joined observer resource` 9.0–10.7), at the cadence 24–30 µs, `joined select 10K factory` 0.43–0.65 ms. +- **`joined observer 10K` 0.35–0.54 ms → 0.52–0.69 ms a tick:** posts are now claimed only once written and marked handled by the post claimed, so the world waits, past four cancels, for a post the thread really runs; before, a thread claimed a half-written post, ran it again once it saw its posted word, and the world, settled by the first run's done word, went on without waiting (and once in 13 runs waited for good on a post the thread had marked handled unclaimed). + +Observer posts on joined threads holding copies, alternating against `e3f51fb3`, node, three rounds on a machine loaded by other work; every other touched benchmark within noise (`joined observer 10K` and `joined select 10K factory` over six rounds, `remote run`, `run idle`, `run observer`, `observer idle 100`, `read run observer`, `read observe factory cold`, `loop tick idle`, `handoff`, `tail prompt`), and in chromium `remote run` and both 10K benchmarks over five rounds: +- **An observer system reading only struct resources posts copies of them,** its head's flags and its resources' values, so writers and sync points leave its posts alone; the next post cancels one no claim reached, takes the outcome of one that ran, and goes into a second block while the thread runs one, its outcome taken as its flags say. Back to back, `joined resource factory` 6.9–8.2 → 4.3–5.3 µs a tick and `joined observer resource` 6.9–9.9 → 4.3–5.0; in chromium 10.0–10.4 → 7.0–8.1 and 9.3–10.5 → 7.2–7.5. Before, back to back, the world copied each post's resource for its next writer (0.54 µs a call through the tail snapshot), settled it at the sync point, and waited, past four cancels, for the thread's wake-up and run (2,746 waits in 20,000 ticks). +- **At a display's cadence the cost is cache lines and cold code,** not work: frames 2 ms apart, a post's writes take 0.13–0.29 µs on its first line, which the thread's claim last wrote, and its whole pass 3.3–4.3 µs a tick at the median. So a post writes only its block's first and last lines: its snapshot's words once, its flags when they change, its resources in its last line up to 80 bytes, and an end new each post, so the thread's done word needs no reset; its head is worked out once, and with nothing the GPU wrote to read back it's written at once, without a list of posts. Two worlds in one process, a frame each in turn, 500 ticks each over six rounds, three processes: `joined resource factory`'s world 9.0–9.8 µs a tick at the 10th percentile and 17.5–20.9 the mean of the middle half; at `e3f51fb3`, 2.3–4.6 µs more at the 10th percentile. The cadence benchmarks, a process each, still vary up to twofold with the machine's load: `joined resource factory cadence` 34–41 → 18–35 µs. +- **An observer pass whose systems all run on joined threads** skips the world's own pass over them. +- **The cadence benchmarks give the mean of the middle half of 1,000 ticks,** after 1,000 to warm up, rather than the least of eight stretches of 100: a few ticks take many times the rest. + +Benchmarks read through observer systems and the factories (E of the observers plan), alternating against `e3f51fb3`, 3 to 5 rounds in node and 2 in chromium, and the joined, cadence, `remote run`, `tail` and `handoff` ones again against `8ce1ea60` (observer posts holding copies, the cadence benchmarks' mean of the middle half), 4 rounds in node. Ticks back to back are counted by an observer system filtering on the clock's `changed`, reading nothing (`perftest/observer.ts`); what's read before the timed runs is read by observer runs, which leave nothing added, and results are checked through the factories once nothing more is timed, as they keep their maintenance system added; the cadence benchmarks time each frame until the loop asks for the next, its tick done, rather than until an observer of the clock was notified. Every touched benchmark is within noise in node (`loop tick idle`, `observer idle 100`, the `loop tick add remove` ones, `tail prompt`, `tail late snapshot 10K`, `tail overlap`, `tail overlap waiting`, `handoff`, `fused tiers`, `remote run`, the joined ones, `joined resource factory cadence`, `create flat`, `create flat dynamic`, `move`, `churn`, `retag`, `emit`, `emit main`, `reduce`, the `up` ones, the `lookup 1K entities` ones) and in chromium, two rounds of the suite each side on a busy machine (`loop tick idle` 4.4–4.6 µs both, `tail prompt`, `handoff`, `remote run`, the joined ones, `transfer`, the placed ones), but one: +- **A tick's clock can't tell a tick from the last in chromium:** its timer is coarse enough that back-to-back ticks share a `now`, so a counter skipping a clock whose `now` it had seen counted too few ticks there: `loop tick idle` 4.3 → 6.0 µs. The counter filters on `changed` instead; a tick-count wait (`ticksAfter`) tells ticks by their count and `now` together, as the first tick's count is 0, as before it. +- **The order modules load in matters:** imports added at the top of `benchmarks.ts`, or `src/observe` and a module reading the clock imported where they weren't, reordered the bundle's shared chunk or which keys register first: `tail prompt` took 26 µs a tick against 21 in node, `remote run` 16.5 against 12.4 in chromium. New imports come last, or where the clock's was. + +The built-in observers and mailboxes removed (F of the observers plan), alternating against `87a81495`, node, four rounds and six for the closest: runs and ticks no longer start, read back for or deliver to observers. `run observer` 208 → 166–167 ns, `run idle` 375 → 334–375 ns, `joined resource factory` 5.0–5.3 → 4.3–5.0 µs a tick, `joined observer resource` 4.4–5.6 → 4.1–4.6 µs; within noise: `run 50 idle`, `observer idle 100`, `loop tick idle` (1.72–1.85 against 1.71–1.79 µs), `sync point one spawn`, `remote run`, `handoff`, `fused tiers`, `tail prompt`, `tail late snapshot 10K`, `tail overlap`, `joined observer 10K` (0.40–0.49 against 0.40–0.47 ms), `joined select 10K factory`, `read run observer`, and `read run`, 9.5–11.4 against 10.1–11.8 µs, a new system each read. + +A report for every run and tick, `Report.run` written in place and `Report.errors` only with errors, alternating against `76f4670e`, node, six rounds, medians: `run idle` 333 → 458 ns, `run observer` 166 → 291, `run 50 idle` 3.63 → 3.75 µs a run, `loop tick idle` 1.70 → 1.92 µs a tick, `sync point one spawn` 875 → 1,000 ns, `read run observer` 7.9 → 9.1 µs, `remote run` 10.0 → 10.6 µs, and `run failing` 12.0 → 9.9 µs, its errors joining its own sync point rather than one of their own. +- **About 125 ns a run:** two `performance.now()` calls for `elapsed`, 31 ns each, then locating the report, writing its struct and stamping it. +- **`Report.run` is written field by field** through its layout's leaves, not the general struct writer: `run idle` 583 → 500 ns, and `run observer` 375 → 291. +- **What a tier ran is worked out once per list of runners:** counting each runner every run took `run 50 idle` to 4.08 µs a run. +- **A reused report keeps its errors and takes new ones in place,** so a run with a blocking tier every tick changes no archetype, and its sync point isn't one tails wait for. + +Strings in shared memory, alternating against `c6bb3c83`, node, five and six rounds, medians: every touched benchmark within noise (`run idle` a mean over 20,000 runs 626–644 against 622–660 ns, `run 50 idle`, `run observer`, `loop tick idle`, `sync point one spawn`, `move 1K`, `main thread 1K`, `spawn bulk`, `despawn bulk`, `despawn one`, `add tag`, `add value`, `remote run`, `joined observer resource`, `joined observer 10K`, `handoff`, `tail prompt`, `tail late snapshot 10K`, `despawn objects 10K` 97.5 against 97.7 ns). +- **The world's thread holds strings as values,** as it does objects, and reads and writes them there: `string read 10K` 2.78 ns a row, as `object read 10K`; `string write 10K` 19.5 ns, `object write 10K` 20.5. +- **Records only while a joined thread may read them:** `string write 10K joined` 126 ns a row, a new record each and the old one let go. Most of it is copying the code units out of a string just made, which flattens it first: in a microbenchmark, 106 ns a 30-character label made and copied against 37 copied once flat. +- **A joined thread reads a string out of its record, decoded once per record:** `joined observer strings 10K` 358 µs a tick, an observer system there reading every caption after each sync point while the world rewrites 1% of them, against 309 µs for `joined observer numbers 10K` reading numbers. +- **Records up to 512 bytes are carved from slabs** by power-of-two size class: `string write 10K joined` 142 → 127 ns against the arena's blocks, whose size class costs a `Math.log2` per record. +- **A removed row lets go only of its archetype's string storages,** listed once per archetype: `despawn objects 10K` 104 → 97.7 ns against a check of every storage. + +Sync points waiting only for tails that read the world's memory, against `c21e8e1e`, node: `tail late snapshot 10K` 474–484 → 9.0–9.5 ns a row copied, against 8.9 for `e3c30aa1` measured beside it (its record 8.2), and in chromium 450 → 6.0 ns (its record 5.0, one or two 5 µs timer steps); within noise in both: `tail prompt`, `tail overlap`, `tail overlap waiting`. Since every run spawns its report, until 256 are, almost every sync point has something to apply, and each one settled every tail, so the run waited out the whole body of the benchmark's 4 ms tail ahead of the late one, whose head it had claimed. +- **A tail reading only its own thread's objects isn't settled at a sync point,** having nothing there a sync point writes: 474 → 12.8 ns. +- **The benchmark times the run alone,** reading its report after: 12.8 → 9.0 ns. Reading it through `runReported` timed a second run as well. + +GPU copies uploading only the chunks CPU writes changed, against `a4eef10e`, chromium, three runs each: new `gpu upload one row 100K`, a target system writing one of 100K 12-byte positions a render system draws, as a drag does, 0.44–0.47 → 0.048–0.058 ms a run; the upload writes the row's chunk of 1,024 rows, 12 KB, rather than the whole 1.2 MB column. New `gpu upload all rows 100K`, a CPU kernel writing every row before the draw, within noise: 5.35–5.55 → 5.24–5.47 ns a row; `observe gpu entity factory` within noise, 0.44–0.51 ms both. +- **Components a GPU kernel or render system uses keep ticks,** and kernels writing them keep their chunk stamps, though not their row stamps unless a change filter observes them: once per call for a kernel writing every row, a chunk stamp per written row otherwise. +- **An upload writes each run of adjacent chunks stamped since its last upload** or holding rows a sync point appended or disturbed, clamped to the row count; a column with GPU writes the CPU hasn't read back writes only those rows. A whole-column stamp (`columnChanged`) uploads nothing: only GPU kernels make one, and their writes are on the GPU already. +- **A WASM kernel's column addresses are kept per load as well as per archetype version:** a kernel loaded again to stamp a component that already kept ticks went on passing the muted column's address, 0, and wrote its stamps over the entity table there. + +## Where the gains came from + +Step figures marked (vitest) are from the node vitest lane: read them as relative. + +**Structural changes.** +- Commands as parallel typed arrays, a reusable typed plan, and cached archetype edges: create 198 → 68 ms, churn 22 → 10 ms, retag 24 → 11.7 ms, emit 7.3 → 3.1 ms (vitest). +- `Object.keys` instead of `for…in`, and buffers reused across runs: small gains. +- Allocation-free kernel commands (`begin`/`value`/`component`/`spawn`): emit 3.2 → 1.98 ms (vitest). +- Inline value writes instead of closures, an 8-shape spawn cache, edges indexed by component id, archetypes grown once per sync point: create 38 → 27.6 ms. + +**Change filters.** +- Chunk ticks, the newest tick per 1024 rows, so filters skip unchanged chunks: clustered sparse to ~0.09 ms. +- Filters inside WASM kernels, one call per archetype instead of one per passing range: uniform sparse 3.5 → 1.15 ms, clustered to 0.02 ms. + +**Lookups.** +- Precompiled matchers with component ids resolved once: follow 10.4 → 4.8 ms. +- Lookups in WASM through a directory per lookup in shared memory: follow 4.8 → 0.37 ms. Exact entity decoding costs about 0.07 ms, partly recovered by an integer fast path. + +**Kernel hot path.** +- Full 16-byte loads for three-lane rows: move 0.262 → 0.204 ms. +- Four rows of whole interleaved elements as full vectors (three for tight vec3) instead of one partial vector per row: move 0.177 → 0.095 ms, chain 6×1M 3.5 → 1.55 ms, chromium main thread move 0.200 → 0.105 ms. +- `changed` stamps only for observed components: move 0.204 → 0.175 ms, chain 0.47 → 0.344 ms. +- Tick columns only for components some system filters on, started at the current tick when the first one registers: create 1M 325 → 296 ms, retag 1M 79 → 68 ms, churn 1M 54 → 50 ms. For the particle fixture, ticks were 80 of 112 bytes per entity. +- Exact integer `%` in WASM instead of a JS import. + +**Dispatch.** A task ring per worker in shared memory with `Atomics.notify`, and fast kernels split at half the minimum chunk: chain on workers 0.485 → 0.255 ms, then 0.16 ms with the kernel gains. + +**Claimed splits.** Workers claim pieces of a WASM kernel's rows from a shared counter instead of taking even shares, so the slowest core no longer sets the finish time: on the default pool, move 0.055 → 0.030 ms, follow 0.15 → 0.075 ms, chain 0.16 → 0.115 ms, sparse changed 0.30 → ~0.22 ms. + +**Affinity claims.** Each worker owns a home range of a split's pieces, the same one every run, takes them from the front, and steals from the back of the fullest other range once its own is empty. Pieces stay on the same worker from one tier to the next 75% of the time instead of 13% (no better than chance), so tier N+1 finds its rows in the cache tier N warmed: chain 6×1M on workers 0.79–0.87 → 0.275 ms, per tier 230 → 170 µs in traced medians; move 1M on workers 0.020 → 0.015 ms. Control words, claim words and status slots each take whole 128-byte cache lines, so a store by one thread doesn't invalidate the line another polls. + +**Staged spawns.** A main-thread `commands.spawn` writes its values into a private batch per component set, in the target archetype's layout, and the sync point block-copies each batch into its archetype before the plan, placing entities and stamping ticks with the sync tick. The issuer's tuples are read while fresh and die young, and the plan, shape match and per-component writes at the sync point go away: create 1M 300 → 153 ms, churn 29 → 21 ms. The plugin rewrites each `commands.spawn({ ... })` with literal keys in a main-thread system into a per-site function taking one parameter per value, so no value object is built, nothing is looked up by key, and each value goes through a writer specialized once per shape to its column's layout, called only from that site: create 1M 153 → 88 ms, flat 181 → 70 ms, with no code generated at runtime. adobe/data's ecs2 inserts the flat particles at about 57 ns each, immediately and without deferral, after building each value object. Spawns of objects built beforehand read values by key in a loop, about 3× slower. + +**Structs.** A struct's read and write, and a nested tuple's, are generated per layout as straight-line code: one literal of its leaves' loads, and one store per leaf, where a closure per leaf built the object a property at a time and read each member through `Reflect.get`. Where `'unsafe-eval'` is forbidden the closures run. `struct copy 100K`, a main-thread row system copying a five-number struct, 147 → 17.6 ns a row in node and 20.4 in chromium; `struct replace kernel main`, a struct and a vec2 added per row as commands, 144 → 118 ns a command. A command of an entity following its previous one in the same buffer, as a body's adds to one row do, extends that one's range of values rather than listing another range in a map: 118 → 60.9 ns, 70 in chromium, 61 on chromium's workers. + +**Change sets.** An entry between the same archetypes as the one before it, whose one range of values names the same components, joins that one's group without listing its replaced components or building a group key, as one system's commands over one archetype make: `struct replace kernel watched`, a changes finalizer watching 200K replacing adds, 187 → 76.8 ns a command, against 63.6 unwatched. + +**Joined threads.** A wake notifies only a joined thread that has read every earlier one, by a word the thread clears before it reads what's new, so back-to-back ticks don't notify a thread still busy with the last. A wake landing between the thread's clearing the word and its wait notified no one, and the word then kept later wakes from notifying, so a reader there fell behind and stopped being woken (4,412 of some 6,000 values seen); with the thread reading again while the word is set, it sees every value. + +**Structure epoch.** Kept only while a joined thread holds claims, turned even by a store and notifying only a reader that waits: `sync point one spawn` 916 → 833–875 ns in node, measured alternating with `c4ecbd8d`, from before phase 4, at 833–875 ns. Two atomic adds and a notify cost about 35 ns a sync point, an add and a store about 24 ns. + +**Joined threads' objects.** A rebuild fills each column with its start first and indexes the old rows by entity only for a value the thread gave and kept: `log rebuild 100K` 9.7 → 4.1–4.8 ns a row in node. Their ticks are the world's storages' alone, which the thread's systems stamp, rather than copies the mirror kept beside its columns: `log replay` 24 → 20.6 ns a move and `log rebuild 100K` 4.31 → 3.21 ns a row in node, 5.3 → 3.45 in chromium. + +**Systems on joined threads.** A tier, or consecutive tiers, on a joined thread is a task through its task block. The world spins about 20 µs for it before it waits, and the thread that finished one spins as long for the next, each about one wake-up's latency: `handoff` (five tasks a tick) 68 → 11.5 µs a tick and `fused tiers` (one) 19.3 → 7.5 µs in node, 13.2 and 9.4 µs in chromium. Ten tiers fused into one task cost about what one tier's does. + +**Finalizers on joined threads.** Stop lists are written a column at a time, the linear structs copied by 32-bit word and each column's storage found once per run of an archetype, and despawned entities' held indexes counted in an array by index: `despawn objects 10K` 176 → 83 ns a despawn in node, against 77 ns with the same finalizer run inline on the world's thread. A `Map` of holds, and a `copyWithin` per stop and struct, cost most of the difference. + +**Tails.** A tail's snapshot is laid out for the rows it found passing and copied a run of consecutive rows at a time, its entities and each linear read one `set` each, its positions, archetypes and rows written in place, and tags marked only for a tail that writes objects: `tail late snapshot 10K` 29.8 → 6.7 ns a row in node, from a row at a time through a scratch list, whose tag pass alone cost 3.3 ns a row for a tail that writes nothing. The copy is what lets the world's work after its writer overlap a late tail: `tail overlap` 2.53 ms a tick against 4.70 for `tail overlap waiting` in node (below). + +**Indexes.** Node numbers, `pnpm perf`. +- Keeping one current costs nothing while its entities rest: a refresh checks its journal position and its key parts' last writes, which systems' writes and sync points' values mark per component, `index idle` 9 ns an index. Moving 1% of 1M entities to other keys, `index refresh 1M 1%`, 4.3 → 1.56 ms: rows listed with their archetype and row, without marks when one key part's scan is all a refresh reads, short buckets' operations applied as they come and long ones merged once, ordered by a counting sort by bucket; a refresh whose changed chunks' rows pass an eighth counts the rows' own ticks before building again, so a few writes spread over every chunk patch: `rekey 1M`, 1M particles 6% of which cross into another cell a frame, 46.9 → 14.1 ms, against 4.8 ms for the same frame without the index. +- Lookups: a call site's encoding, hash and probe generated for its shape and the index's layout, buckets as records of hash, segment and key beside two-word slots of hash and bucket, and a row bound only when its archetype changed: `unique lookup 1M` 86 → 45–50 ns. A microbenchmark of the same probes over the same table reads 29 ns sequential and 85 random a lookup; an identity hash for integer keys is under "Tried and not kept". `children lookup` 32 ns a child, `grid neighbors 100K main` 10.5 µs a row of 27 probes. +- Sorted ranges: a search over a compact array of each block's last key and entity, comparing words only for keys of words, and `at` walking blocks through a cursor: `sorted range 1M` 1.08 → 0.76 µs a range of ten; `sorted page 100K` 22 µs a change against 1.12 ms for `select page 100K`. +- Kernels: probes in straight-line WASM over the index's arrays, `grid neighbors 100K workers` 179 ns a row across nine workers. +- Building again in phases over fixed pieces and partitions, the listing generated for the key's leaves: `index rebuild 1M main` 37 → 27.3 ms on the world's thread alone, and 6.7 ms across nine workers, the four phases about 1.3, 1.9, 1.3 and 1.8 ms. One worker takes 29 ms, so nine scale it 4.3×: each phase scatters or gathers by partition, and a single-threaded microbenchmark of grouping alone runs at 4.8 ns an entry, branch and memory bound. +- `order run 100K` 31.4 ns a row against 28.3 for the same body in storage order: main-thread bodies walk the index's entries, bound again where the archetype changes, rather than listing rows first (34.5 ns). `order filtered 100K 1%` 180 ns a changed row. +- Lists and their rows of one hidden class: lists defined their accessors as an object literal's, new closures each, so every list after a site's first had dictionary properties, and each pooled row had a map of its own, so a body reading a bucket was megamorphic. Lists and rows now define shared functions in the same order and read their state through `this`; a lookup makes its pool's rows ahead of `at`, the first 256 positions and, for sorted lists, the last 256, so one-time work stays out of what a body inlines and the row getters fit its budget; on the world's thread a row checks one count of replaced arrays, not its archetype's version. `grid neighbors 100K main` 10.6 → 3.66 µs a row, `children lookup` 32 → 13.1 ns a child, `sorted page 100K` 22 → 5.0 µs a change, `sorted range 1M` 760 → 563 ns a range. What's left in `grid neighbors 100K main`, against about 1.6 µs for the kernel on one worker: its 27 probes, 24 of which miss, about 0.7 µs at a half-full table (2.4 slots a probe; a JS microbenchmark of the same probes takes 0.51 µs), and about 6 ns an `at` and 4 a read for each of its 260 neighbors, the loads of the list's state, the pool and the row's slots that TurboFan doesn't hoist out of the loop. +- Refreshes: a refresh lists changed rows once, building again when they pass an eighth, rather than counting their ticks first and scanning again; entries shift an entry a step; whether a key holds objects is kept, not scanned for per entity: `index refresh 1M 1%` 165 → 141 ns a moved entity, `rekey 1M` 14.5 → 12.3 ms. With 100K entities, all in cache, a move still takes 145 ns, so it isn't memory bound: two ordered edits, a binary search and a shift in the old bucket and the new, take 57 ns in a microbenchmark, and finding the key's bucket and the per-entity bookkeeping the rest. +- Indexes without a key, kept as sorted tables with no key words: `keyless refresh 1M 1%`, 1% of 1M entities spread through entity order leaving or entering again, 343 → 187 ns an entity, from padding entries to four words, so a block's shift moves 8-byte words, which a copy within shared memory moves about five times faster than 4-byte ones (a 1.8 KB shift 622 ns by 12 bytes, 128 by 16; a loop is no faster), and binary searches by entity within a block; what's left is a shift of about half a block and a search over 10K fences each way. `keyless spawn 1M 1%`, 1% despawned and spawned again at the end of entity order, 22.4 → 2.1 ms: a split or dropped block refreshed every position, fence, Fenwick node and segment tree, and now those from its position on. `keyless length` 60 → 4.8 ns a lookup reading `length` and `changed`: a site without arguments copies no values, the plugin's count-0 site calls it directly, and a whole index's `changed` reads its tree's root rather than two Fenwick descents. `keyless write 1M`, a refresh after a kernel wrote a component all 1M hold, 3.6 µs, as `keyless index idle`'s 7 ns an index: it reads no key ticks. +- `unique lookup 1M` stays at 44 ns: a build numbers buckets by slot partition, so a lookup in key order reads its slot, record and entry at three unrelated places; a JS microbenchmark of that chain takes 58 ns, and of the slot probe alone 17.8. + +**Hot helpers kept small.** TurboFan inlines what a system's body calls into its runner's loop up to a cumulative bytecode budget, so what's left for the row getters depends on everything else the body reaches. `pushCommand`'s growth, a prepared spawn site's plan lookup past the one kept by archetype index, and a list's shape and spawner past the cached ones are outlined: churn 15 → 18 ms in node had come from code elsewhere in the bundle, a dead function placed before `finalizers.ts`'s runner reproducing it on `fe36a99f`, and with `--max-inlined-bytecode-size-cumulative=2000` both measured 14 ms; outlined, churn is 14.9 ms on `fe36a99f` and 15.0 ms here. + +**Archetype moves.** Each storage knows its element size in 32-bit words, so a cell copy doesn't dispatch on the layout, and move lists are cached in an array by target archetype id rather than a map: retag 1M 46.5–48.8 → 41.8–44.2 ms. + +**Main thread.** Row getters chosen once, and tuple reads and writes in one loop: 50 systems 3.2 → 1.63 ms (vitest). +- Accessor rows each get their own prototype. Built from `{}`, every row after the first that defines an accessor with another getter went to V8 dictionary properties. One row per set of reads and writes, shared across systems and archetypes and rebound per pass, so a body sees one shape; f32-lane tuples read as array literals, and scalars and f32 lanes written directly. Resource cells in a context are resolved once per run. On node: 50 systems 1.1 → 0.45 ms, churn 55 → 32 ms, retag 74 → 59 ms; 50 distinct bodies over three row shapes 1.75 → 0.53 ms; two writing systems over 100K 19.5 → 1.9 ms; 20 systems reading a resource per row 1.26 → 0.22 ms. + +## GPU particles + +The sample (`pnpm sample`, which opens `/particles/`) draws the visible and enabled quarter of the perf particles as points, wrapping positions in the vertex shader, and moves them with the perf `move` kernel: on the GPU (`?mode=gpu`, positions never come back), on workers with the positions uploaded for drawing each frame (`?mode=cpu`), or not at all (`?mode=static`). Headless chromium on Apple silicon at 60 fps, 10 s per sample after warmup, CPU time per frame by process from `ps`, main-thread task time from the DevTools protocol. Four rounds at 1M (250K drawn), two at 4M (1M drawn): + +| Per frame, ms | draw only | CPU move + upload | GPU move | +|---|---|---|---| +| `run`, median (p95), 1M | 0.28 (0.6) | 0.75 (3.9) | 0.39 (0.75) | +| main thread busy, 1M | 0.95 | 1.9 | 1.02 | +| renderer process CPU, main thread and workers, 1M | 2.2 | 4.9 | 2.3 | +| GPU process CPU, 1M | 2.9–3.0 | 2.2 | 3.1 | +| `run`, median (p95), 4M | 0.2–0.45 | 0.97 (1.9) | 0.40–0.46 (0.87) | +| renderer process CPU, 4M | 1.5–2.7 | 5.55 | 2.6 | +| GPU process CPU, 4M | 2.0–3.3 | 4.2 | 3.2–3.3 | + +- **The GPU move costs about what drawing alone does:** about 0.1 ms more `run` and 0.1 ms more renderer CPU, the same at 250K and 1M drawn: one record write and one dispatch per archetype. +- **The CPU move costs about 2.7 ms more renderer CPU per frame and twice the main-thread time,** though the move itself is 0.015–0.03 ms on workers. The rest is waking workers inside a frame loop and the 3 MB (12 MB at 4M) upload, copied on the main thread by `writeBuffer` and again in the GPU process, whose CPU rises at 4M. +- **The GPU process uses less CPU in the CPU mode at 1M** than with nothing moving at all; not understood yet. + +## Worker scaling + +move, chromium, by worker count and entity count (a quarter of the entities move), with even shares: + +| Workers | 1M | 4M | 10M | +|---|---|---|---| +| 0 (main thread) | 0.200 ms | 0.785 ms | 1.98 ms | +| 1 | 0.195 ms | 0.785 ms | 1.97 ms | +| 2 | 0.100 ms | 0.410 ms | 1.01 ms | +| 4 | 0.045 ms | 0.215 ms | 0.535 ms | +| 6 | 0.030 ms | | 0.500 ms | +| 7 | 0.025 ms | 0.150 ms | 0.56 ms | +| 8 | 0.060 ms | | 0.555 ms | +| 9 (default) | 0.055 ms | | 0.575 ms | + +- **Dispatch overhead is not the limit.** Scaling is linear up to 7 workers at 1M: 0.025 ms is 8× the main thread and 3.6× the adobe/data ECS. +- **Even shares wait for the slowest core.** The default pool is `hardwareConcurrency − 1`, which is 9 here. Past 7 workers, shares land on efficiency cores or share a core with the main thread, so 1M more than doubles. Browsers expose no core types or thread placement, so claimed splits fix this instead: with them, 1M takes 0.030 ms at 7, 8 and the default 9 workers. Pieces per worker (4, 8 or 16) made no measurable difference. +- **At 10M the work is memory bound.** 2.5M rows read 24 bytes and write 12, about 90 MB per run; 0.5 ms is about 180 GB/s, near the chip's CPU bandwidth. It stops improving after 4 to 6 workers, and more entities would not change that. +- **Single-threaded move matches the adobe/data ECS** (0.095 ms against 0.09 ms): four tight vec3 rows are three full vectors, as its separate x, y and z arrays are. + +## Tried and not kept + +- **Observer posts without allocating per post** (a reused list of chosen posts, no closure for posting, the block's cleared words a constant list, settling over reused scratch rather than a copy of the posts): `joined observer resource` 7.2–7.6 µs a tick against 6.4–8.3 without, `joined observer 10K` 0.52–0.69 ms against 0.51–0.76, within noise. Worth revisiting once posting shows in a profile of a world with many observer systems on joined threads. +- **Observer factories: a count or selection with `where` tracked through an index without a key,** every row read through `at` and tested after any change: `count 1M changed factory` 23.2 ms; a sorted index on the `where` key, each entry looking up its range, takes 42 µs. Kept for selections with a page and no `order`, which stop at the page's end in entity order. +- **Observer factories: descending pages finding each run of equal keys by galloping back from its last row,** three reads of most rows: `select page 100K factory` 23 µs a change; reading each row once, holding a run until its key changes, 16 µs. +- **Observer factories: a scan's result copied out of scratch,** `select factory` 14.3 ms; gathered into the value itself, with the test inline for one entry and entity indexes by `>>> 0`, 10.3 ms. What's left is a row body a row; worth revisiting with a way to read columns in an observer system without a body call per row. +- **Observer factories: keeping a resource's notifier added once idle,** a cold read 2.6 µs (a run of the reader alone) against 14.0 µs adding it again, nearly all of it registering its runner; kept removing it after three idle passes, since each added notifier costs every pass. Adding an observer system again now reuses its runner (0.1 µs of a 0.88 µs cold read), so removing stays. +- **Observer factories: a direct scan's numeric condition tested inline,** its range's bounds compared in the body rather than through the condition's test: `select factory` 8.55 ms against 7.55, the larger body costing more than the call it saves. +- **Observer factories: gathering every entity into a plain array, its value a slice,** 9.5–9.65 ms against 9.1 for typed scratch copied into an array made at its length. +- **One block for posts holding copies, the next post waiting for one in progress:** back to back the world waited for the thread's run in 1,696 of 20,000 ticks, about 10 µs each after a 5.6 µs spin, 1.3 µs a tick; a second block, which the next post goes into while the thread runs one, removes the waits. Worth revisiting only if a block's memory mattered. +- **The cadence benchmarks giving the median tick, or the median of ten stretches of 100:** chromium's timer steps are as long as a tick, so its median tick was 20, 25 or 30 µs; a stretch's mean holds its slowest ticks, 25–113 µs a tick on a loaded machine against 18–40 for the mean of the middle half of the ticks. +- **An observer system on a joined thread whose unclaimed post a writer cancels rather than copies:** `joined observer resource` 6.0–6.3 µs a tick against 6.2–6.6 copying, within noise; kept copying, since a thread that claims between the writer and the sync point then still runs. Worth revisiting if copying a large read set per tick shows in a profile. +- **Waiting for the post after one cancelled (`cancelsBeforeWaiting` 1), or after sixteen:** at 1 the thread runs at least every other sync point, but the world waits for its wake-up nearly every other tick back to back: `joined observer resource` 11.4–15.8 µs in chromium against 9.3 at 4, `joined observer 10K` 0.50 ms in node against 0.30. At 16, 6.4 µs and 0.11 ms in node, 9.2 µs and 0.12 ms in chromium, the thread up to 17 sync points behind. Without waiting at all, `joined observer 10K` never ran in chromium in 3,000 back-to-back ticks: every post was cancelled first. Kept 4. Worth revisiting with a cadence-aware limit, such as milliseconds behind rather than posts. + +- **A loop spawner as the only spawner:** about twice the generated one (numbers above). Kept only where code can't be generated. Worth revisiting if engines specialize a shared loop's keyed reads per shape. +- **Prepared spawn sites calling the shape's column writers:** one site spawning into four archetypes made each writer call see four closures, `create` 103 ns and `create flat` 76 ns, against 58 and 66 ns through a positional spawner generated per site key order and shape. +- **Removing the prepared sites,** as the plan for curried spawns proposed once they no longer won: they still win by the values object's cost (above). +- **A spawn site remembering the last archetype and world it spawned into,** checked before the plans by index: `spawn singles` 390 → 380 ns, within noise, but `create` 58 → 61.5 ns and `create flat` 64 → 70.5 ns. Worth revisiting if a site's plan lookup shows up in a profile again. +- **Removing rows last first without grouping moves by pair:** `add tag` 114 ns and `retag` +10%; grouping the arrivals by pair took them to 100 ns and −5%. +- **Main-thread kernels spawning into the world's batches,** as main-thread systems do, rather than their runner's: `spawn bulk kernel` 17.8–18.6 ns against 17.8–19.3 ns, no gain, and the rows of chunks run on workers would land after every main-thread chunk's, so row order would depend on the worker count. +- **Blocks of reservations whose unused positions the leak scan finds,** rather than returned by position: `spawn bulk kernel` 15.5 against 10.3 ns, and the scan bumps the generations of indexes never handed out, which wears them toward wrapping. Kept for chunks that fail. +- **A first block of 64 positions:** 10.3 ns against 10.4 ns starting at 8; 8 is kept, since a kernel spawning a few entities a run returns fewer. +- **Kernel adds and removes staged as spawns are:** not built. A profile of `add tag kernel` (81.5 ns, below `add tag`'s 96 ns) puts issuing in the kernel at 12% and the sync point at the rest: plan 29%, arrive 19%, sort 12%, writes 7%. Staging would have to bypass the plan, which orders adds against despawns and other commands. Worth revisiting with bulk moves in the plan (design.md). + +- **Listing symbol keys with `Reflect.ownKeys`, or with `Object.keys` first:** 1–5% slower than `Object.getOwnPropertySymbols` for 1 to 6 keys (table above), and `Object.keys` finds nothing, since keys are symbols. Worth revisiting if engines give symbol keys an enumeration cache. +- **U32 ticks:** no measurable gain on the non-sparse 1M benchmarks, 10% on uniformly sparse filters, and change filters go wrong past 2³² ticks unless a clamp pass is added. With ticks kept only where observed, it would help only observed components. +- **Commands encoded from WASM:** emit 1M 11.2 → 9.2 ms on the main thread and no gain on workers, for about 800 lines across the compiler, code generation, runner, pool, worker and sync point. The sync point is most of emit's time. +- **Workers spinning 0.15 ms instead of 0.05 ms before waiting:** chain 6×1M on workers 0.83 → 0.525 ms back to back, but only because it bridges the gap between consecutive benchmark runs; per frame it measured the same. +- **Holding workers spinning while a later tier of the run has kernels,** with no time constant: per frame the same, and move's best case on workers 0.020 → 0.040 ms. Traces showed the 0.05 ms spin already keeps workers awake across tiers (each tier is seen within about 5 µs). +- **Home ranges sized by each worker's recent share of pieces:** no gain (chain 0.275 → 0.29 ms). Shares never settle: apart from the slowest few workers, each swings by about 40% between rounds, so the sizing chases noise and moves range boundaries, which costs affinity. +- **Copying numbers and f32 tuples out of command values when issued,** into a typed array the sync point reads, so the issuer's arrays die young: the sync point got 27 ms faster and garbage collection 12 ms, but issuing got 96 ms slower per million spawns (create 1M 294 → ~357 ms). Reading the tuples costs as much at issue time as later, and the extra bookkeeping is more than the sync point saved. +- **Segments of records, a command at a time, decoded into the world's buffers:** the decode cost 0.9–0.98 ms per run of chromium `emit workers` (100K commands), as much again on a second pass over the same bytes, so instruction-bound, not memory-bound, which put `emit workers` at 8.6–8.8 ms against `efedcd21`'s 8.2–8.4 ms; in node 6.83–6.93 ms against 6.94–7.03. Decoding command by command through `pushCommand` and a per-component push, rather than into arrays fitted once per segment, was slower still: node 7.24–7.49 ms. Columns copied in by the worker, which the world takes views of, took it to 7.76–7.8 ms. +- **Copying a segment out of the shared ring before decoding it:** chromium drain 0.98 → 1.49 ms; the decode didn't get faster, and the copy cost 0.5 ms. +- **Inlining the one-word copy of a struct value in `writeRange`, and a views sentinel in place of `null` for buffers without bytes:** no change, sync point 6.1–6.2 ms either way. +- **Copying drained columns into fresh, exactly sized arrays:** planning 2.60 → 2.45 ms in chromium, but the copies cost more than that. +- **The main thread claiming pieces too:** no gain; workers that wake late are slow at first, so the main thread does about what it would alone. +- **A loop per main-thread system** (eval or emitted by the plugin): 5–10% on top of fast-property rows; a micro-benchmark of 50 systems with one loop literal each measured 16%. The tuple each `row[component]` read allocates stays, and it costs more than the shared call site. Worth revisiting with compiled main-thread bodies, where reads become column loads. +- **Removing a sync point's rows after all its entries, each archetype's from the highest,** so every row that stays moves at most once: churn 1M +13%, retag +6%. Sorting, the map of rows and a second pass over scattered rows cost more than the copies saved, and copying cells is only about 12% of retag; planning, the system's body and per-entry work make up the rest. Worth revisiting only with column-major moves per pair of archetypes, where removals are batched too. +- **Unrolled f32 lane writers for staged spawns:** create 1M −5% for 30 more lines. Profiles attribute the caller's tuple allocations to the writer, which inflates it. Worth revisiting if spawns stop allocating tuples. +- **Faster value-object spawns** (`create flat dynamic`): `Object.keys` and the caller's own spreads dominate, on a path the plugin already avoids for literal keys. Worth revisiting only if spawns from data objects matter to real workloads. +- **SIMD reductions:** lanes compute in f32 at storage precision, which would round sums into f64 partials. Four rotating scalar lanes took reduce 1M 0.93 → 0.50 ms instead. Worth revisiting with an f64x2 path in the across-rows mode. +- **Faster WASM lookups** (`follow`): the generated find is already an entity-table check, a directory record and column loads, about 3.8 ns per row of dependent loads. Worth revisiting with a measured breakdown of where those cycles go, such as with lookups batched per archetype. +- **Positional `commands.add` and `commands.remove` for literal keys,** as the plugin does for spawns: not built. Issuing them, with their key lookups, is about 8% of retag 1M; planning and applying the moves are the rest. Worth revisiting once the sync point's own cost per move is much lower. +- **Fixed costs per run, measured rather than tried:** a run of a system no entity matches costs about 0.8 µs, fifty of them 0.39 µs each, and a run whose sync point applies one spawn 1.5 µs. Kernels at 1K and 10K rows stay within 2–5× of their best, as at 1M, so small systems hide no large cost. +- **The world's thread spinning up to 50 µs for a joined thread's next run** before waiting on its posted word: `remote run` 12.4 → 66 µs in node, since the spin holds the world's thread and the run it just started waits behind it. +- **Runs from joined threads, measured rather than tried:** two node threads waking each other through `Atomics.waitAsync` take 9.7 µs a round trip, so `remote run`, 12.4 µs, spends about 2.7 µs beyond the platform's floor; spinning instead would block the joined thread, which is often the window's. A mailbox adds about 20 µs a tick to `mailbox select 10K`, whose 0.75 ms is the ordered selection the world's thread recomputes, as it would for its own observer. +- **Checking commands' values for a joined thread's objects as they're issued,** in `commands.add` and prepared add sites, behind a check of whether any thread holds claims: `add value` 95–97 → 100–104 ns in node, measured alternating, with the sync point's own additions about 2% of it. Checked when a system's commands are queued instead, dropping the command and failing its system, where the issuing paths stay as they were; spawns are checked as they're issued, in the object columns' writers, which only objects reach. +- **The structure epoch turned even by a store, notifying only waiting readers, kept at every sync point:** about 24 ns a sync point against 35 ns, and `sync point one spawn` still a timer step (41 ns) above `c4ecbd8d`; kept only while a joined thread holds claims instead. +- **Replay finding a pair of archetypes' source columns once per run of its moves:** `log replay` 23.7–24 → 24.7 ns a move, no gain; a mirror's archetypes hold few columns. Worth revisiting with many objects per archetype. +- **Finding reports to retire through `Report.byRun`,** the sorted index on their run, oldest first: `run failing` 165–196 µs in node acquiring the index for each report and releasing it after, so each rebuilt it, and 87 µs holding it once the first report was spawned, which also keeps the journal recording every later sync point's moves, against 11.7–12.6 µs for the world's own list of the reports it spawned, in run order, with a liveness check per despawn; a failing run without reports, at `3775940d`, is 9.2–9.5 µs, so a report costs about 2.5 µs. Clean runs are unchanged, measured alternating with `3775940d` (`run idle` 333 / 333 ns, `run observer` 166 / 166, `loop tick idle` 1,737 / 1,698, `run 50 idle` 72 / 74 per system, `sync point one spawn` 875 / 833, `remote run` 9.8 / 9.9 µs, medians of six). Worth revisiting only if lookups through a sorted index get far cheaper to set up. +- **Emptying a run's list of warnings by setting its length to 0** each run: `run idle` 375 → 416 ns in node, measured alternating; the list is counted instead, and the report of warnings alone reused while they're the same. +- **Moving `placeFinalizer`,** to the end of `finalizers.ts` or into `activate.ts`, its one caller: churn 17.6–18.6 ms either way, since the budget above, not that function, set it. +- **Writers waiting for a tail to finish instead of snapshotting it,** and **choosing between the two by the tail's rate** (rows read over time since posted, waiting when finishing looked cheaper than copying the rest). Ticks of a world writing every row's place, then working W ms, while the last tick's tail on a joined worker spends T ms on 10,000 rows of a place and a pose, after B ms of other work there; ms a tick, snapshot / waiting / by rate, node then chromium (node's frame is a timer set after each tick): + + | Case | node | chromium | + |---|---|---| + | frame, typical: T 2.5, W 2 | 20.1 / 20.2 / 20.2 | 16.7 / 16.7 / 16.7 | + | frame, B 8 + T 2.5 and W 9: together over a frame | 26.5 / 26.8 / 26.9 | 16.7 / 20.7 (p95 25.5) / 16.7 | + | the same, W before the writer | 26.7 / 26.8 / 26.8 | 16.7 / 16.7 / 16.7 | + | frame, B 20 + T 2.5, W 3: tail longer than a frame | 23.3 / 26.2 / 22.7 | 22.6 / 26.2 / 22.7 | + | fixed 16.7 ms, B 8 + T 2.5, W 9 | 16.7 / 20.2 / 16.7 | 16.7 / 20.2 / 16.7 | + | the same, W before the writer | 16.7 / 16.7 / 16.7 | 16.7 / 16.7 / 16.7 | + | fixed 4 ms, T 2.5, W 3 | 4.0 / 6.1 / 4.0 | 4.0 / 6.1 / 4.0 | + | the same, W before the writer | 4.0 / 4.0 / 4.0 | 4.0 / 4.0 / 4.0 | + + A writer arriving as the tail of 2.6 ms had read 0, about half, 85% and 95% of its rows, chromium, its run in ms: snapshot 0.175 / 0.165 / 0.115 / 0.125, waiting 2.72 / 1.56 / 0.42 / 0.23, by rate 0.2 / 0.205 / 0.155 / 0.13; node alike. The snapshot holds the world's thread 20–72 µs a writer for the copy, waiting 22–27 µs for its spin; the joined thread's time is the same either way, but for one body (Next). By rate never chose to wait in any tick case: a row copies in 6–8 ns, less than any tail's work on it, so finishing is cheaper only within about the last chunk, which the writer waits for anyway. Waiting is worth revisiting if a tick could no longer run ahead of its last tail at all, and by rate if copies grew costly against tails' work per row, as with wide struct reads and light bodies. +- **Dropping a component's kernel stamps as soon as nothing watches it,** reloading the kernels that write it then, rather than at their next load for another reason: ticks that alternately add and remove a system filtering on `changed` of what a kernel writes, 100 rows, took 216 µs a tick against 13.9 µs, in node, since each removal and each add reloads the kernel, its import and WASM compile on every thread, while a stamp kept costs a store per chunk. Worth revisiting only if stamps of components nothing watches showed up in a kernel's profile. +- **Checking eight rows' ticks at every row,** rather than at rows aligned to eight: clustered changes 0.023 → 0.108 ms, since each passing row checked a block again. Aligned checks cost clustered changes about 3 µs. + +- **Records of every string, kept whether or not a joined thread reads them,** read on the world's thread out of a cache by handle: `string read 10K` 23.1 ns a row against 2.79 for an object, and `string write 10K` 154 ns against 21. Values on the world's thread, and records only while a joined thread may read them, put both at an object's cost. +- **Encoding through `TextEncoder.encodeInto`,** into shared memory or a scratch buffer copied in, and decoding through `TextDecoder` from a scratch copy: 96–118 ns a 30-character label made and encoded, against 98 for `charCodeAt` into the record; 98 ns a decode against 124 for `String.fromCharCode` over a view, where a joined thread decodes each record once. Both flatten the string first, which most of the time is; `TextDecoder` rejects shared views in browsers. +- **Copies of strings' records in tail snapshots and observer posts,** let go with the snapshot: not needed, so not kept, since a tick posts its tails once the last tick's are done, a run waits for its tails, and a sync point for every post its thread claimed, so neither outlives the second sync point after the one it read. A tail stalled while runs rewrite what it reads six times sees its tick's strings without them. A post of a system reading only struct resources, which nothing waits for, would need them, so a system reading a string resource doesn't post that way. A joined thread's finalizers, which run whenever the thread replays, read copies in the stop lists. + +- **Index builds across the pool as messages,** a message to each worker per phase in five phases: `index rebuild 1M workers` 9.5 ms on nine workers; each phase paid about 0.4 ms of round trips (counting, trivial work, took 0.5 ms). Phases go through the workers' task rings as tasks of a reserved kernel number, their plan in the memory, instead. +- **A separate scatter phase,** copying listed entries into partition order: 8.35 ms. Counting computes each piece's partition counts and listing writes straight into partition order: 6.7 ms. +- **A loop over the key's leaves for listing:** 14 ns an entity single-threaded in a microbenchmark, of which the member check costs 2.6 ns, the hash call 2 and the loop over leaves 5. Generated code for the plan's leaves runs about 6 ns; the loop is kept where code can't be generated. +- **Grouping with each slot's hash beside its bucket in thread scratch:** 7.4 ms single-worker either way; kept, since it reads less. +- **Building again whenever changed chunks' rows pass an eighth:** `rekey 1M` 46.9 ms, built every frame, since 6% of rows crossing touch every chunk. Counting the rows' own ticks then: 25.9 ms. +- **Merging a refresh's operations sorted by `Array.prototype.sort` with a comparator:** 25% of `rekey 1M`'s profile. A counting sort by bucket and an insertion sort within each: 14.1 ms. +- **Schedule depths as an `Int32Array`,** after `loop tick add remove` grew 234–241 → 260–272 ms with indexes (85b434cb, measured alternating against 2898bf26 and at each later commit): `createSchedule`'s 223 eager deopts on the depths array's map went to 3, but the benchmark didn't move, and timing its sections shows a schedule costs the same, about 2.9 µs, before and after. A larger inlining budget, and runners not making `order`'s closures, didn't move it either. Timed in the benchmark, `System.create` takes about 1 µs more a tick and `add` 0.9 µs more, and a micro-benchmark of `System.create` alone collects garbage half again as long; not found yet (Next). +- **Ordered bodies listing their rows before running them,** as kernels' runs do: `order run 100K` 34.5 ns a row against 31.4 walking the entries. + +- **An index without a key as one hash bucket,** zero key words hashing to one bucket in entity order, which every path already read: `keyless refresh 1M 1%` 6.73 ms against 3.43 for a sorted table then, since a long bucket merges its whole length once any entity enters or leaves, and a spawn per frame into 1M would cost an O(1M) merge. `keyless length` was 5.8 ns, against 4.8 for the sorted table's site once it took no arguments. +- **`copyWithin` for shifts of 3-word entries:** no faster than the loop (`keyless refresh 1M 1%` 3.43 ms either way): a copy within shared memory by 12 bytes moves 4-byte words. Kept only with entries padded to an even number of words (above). Smaller blocks for indexes without a key would halve the shift again, but the 128-entry block is in every reader, WASM included. +- **Index tables at most a quarter full:** `grid neighbors 100K main` 3.93 → 3.73 µs a row, its misses mostly stopping at the first slot, but `unique lookup 1M` 44 → 53 ns, its slots twice as large and less cached. Half full is kept. Worth revisiting for non-unique indexes only, where slots are small next to entries, if misses dominate a real workload. +- **An identity or locality-preserving hash for integer keys:** in a microbenchmark of the slot probe over 1M keys, identity takes dense keys in order from 17.8 to 11.4 ns but scattered keys from 18 to 31 ns, and a miss for a key past a dense range scans the whole run of filled slots, 660 µs; keeping a key's high bits and mixing its low ones packs dense keys into slots side by side, 21 µs a lookup. Linear probing needs the hash to spread keys whatever their distribution; a dense direct table beside the hash would be the way to get the 9 ns, at the cost of keeping a second structure and teaching kernels to read it. +- **Reading a patch's keys, hashes and buckets ahead in batches of 64,** so their misses overlap: `index refresh 1M 1%` 165 → 173 ns and `rekey 1M` 14.7 → 23.7 ms. A move in cache costs the same, so there was little latency to hide. +- **A list's pool in three banks, its first, last and middle positions,** for hash lists too: `grid neighbors 100K main` 3.63 → 3.84 µs a row from choosing the bank in `at`. Only sorted lists keep a second pool for their last positions. +- **Making a pool's rows for every position a list holds:** a 100K-row sorted list made 100K rows for a page of 20; rows are made ahead for the first and last 256 positions only. + +## Next + +- `grid neighbors 100K main`, 3.66 µs a row against about 1.6 µs for the kernel on one worker: each neighbor costs about 10 ns in loads of the list's state, the pool and the row's slots; rows whose values carry their index and views as their own fields would save a few of them. +- Index refreshes, 141 ns a moved entity: two ordered bucket edits and a probe, in cache. + +- `loop tick add remove`, a new system each tick: about 2 µs more a tick since indexes (above), in `System.create` and `add`, as more garbage; the paths a run of an existing system takes measure as before. +- Index rebuilds across the pool: `index rebuild 1M workers` 6.7 ms, nine workers 4.3× one, every phase scattering or gathering by partition; an estimated 2–3 ms if the scatters reached memory bandwidth. Rebuilds are rare, a refresh moving more than an eighth of an index. +- Single-tier runs on workers: workers asleep at the start of a run take 60–135 µs to see the task, and 2–4 of 9 find nothing left to claim. +- Idle cores clock down: after 5 ms idle, even the main thread runs move 2–4× slower per frame, and keeping one other core busy removes most of it, at 10M too. That's the OS's power policy, not something dispatch can avoid without spending power. +- Emit and other command-heavy kernels: the sync point dominates. +- Kernels taking `%` of an f64: a conditional on `resources[clock].tick % 2` costs about 33 ns a row in node, 10K rows 333 µs against 8.5 µs without it. +- Replaying the move log: `log replay` takes 24 ns a move in node and 36.5 ns in chromium, two entries each, against about 1 ns to move the bytes; the cost is the per-entry dispatch and the scattered rows a swap reads. +- Ordered selections recomputed every tick: `joined observer 10K` spends most of its 0.4–0.5 ms a tick sorting 10K entities. +- `joined observer 10K`, back to back: lookups read the rows in place, so the world's writer waits for each run the thread claimed, about 1.07 ms, in 1,451 of 3,000 ticks; the thread's run is about 60% the benchmark's own sort, and of the rest `at` through the index without a key (14%) and a load of the archetype's version per row (12%), which a check of versions once a post would save. +- A tail's body reading from a snapshot ran slower in one case: a loop counted down from an unbounded integer resource (an f64) took 13.6 ms against 11.2 in place over 10,000 rows of 200 steps, in node, while counted as an i32 it took 10.8 against 11.2; deopts didn't account for it. +- Main-thread spawns reserving in blocks too: `spawn bulk` still takes one atomic add per spawn. Finalizers spawn during the sync point, after it materializes the run's reservations, so a world's block would have to end before each materialization. diff --git a/packages/ecs/src/implementation/world/plan.md b/packages/ecs/src/implementation/world/plan.md new file mode 100644 index 000000000..281678a86 --- /dev/null +++ b/packages/ecs/src/implementation/world/plan.md @@ -0,0 +1,81 @@ +# World.create implementation plan + +All of the runtime lives here. The only export is `create`, which moves here from `world/create.ts`. Rules: + +- No dependency on `@adobe/data`. Its code can be read for reference: `typed-buffer/`, `cache/memory-allocator.ts`, `ecs/store/core/`, and `ecs/database/calculate-system-order.ts`. +- Performance comes first, then code size. +- Kernels are compiled from the start. The compiler and Vite plugin run at build time, so they live beside this folder under `src/implementation/compiler/`, with their own package entry. Generated code reaches the runtime through one internal hook, `attachKernel(system, url)`. +- Every module gets unit tests. The particle sample becomes a real end-to-end test. + +## 1.1 Main thread + +1. **Memory.** A shared `WebAssembly.Memory` from the start, so 1.2 and 1.3 don't change how data is stored. + - Columns are typed-array views into an arena allocator. + - Memory grows only at sync points, and views are refreshed afterwards. +2. **Storage.** + - Archetypes are tables keyed by component set. + - Each linear component is a column in the arena. Each non-linear component is a JS array. + - The entity table maps each index to its archetype, row and generation. The free list holds a freed index back until the next flush. + - Each row stores `added` and `changed` ticks per component, as f64 so they never wrap. U32 ticks come in phase 2. +3. **Registration.** + - Flatten composites. + - Check for duplicate system names and for missing schemas. + - Check unordered conflicts, including a lookup that reads a component its own system writes. + - Sort into tiers from `before`/`after`/`during`. + - Build each system's archetype matches lazily, and extend them as archetypes are created. +4. **Running.** + - Run tiers in order. + - Change filters compare against each system's `lastRun`. + - Non-kernel systems get accessor rows: `defineProperty` getters and setters for the declared keys, with copy-on-read and whole-value writes. Values are immutable, so a copy is never edited. + - Resource-only systems run once per run. + - A system that throws rejects `run`. +5. **Commands and sync points.** + - Each system has its own buffer, merged in tier order. + - Plan: compute each entity's net move from one archetype to another, and skip it when nothing is pending. + - Apply: swap-remove, insert spawned rows, then free indexes. + - `spawn` reserves a real entity through the atomic cursor. +6. **Reads.** + - `query.select` and `count`, with `not`, `where` and `order`. + - `read.entity`, `read.component` and `read.resource`. + - Resources are stored as components on a single entity, with their initial values taken from the schema `default`. +7. **Compiler** (`src/implementation/compiler/`): + - The bundler's parser (`this.parse`), plus a validator for the kernel subset. + - Maps each component, resource and lookup access to its slot by binding. + - Emits a JS kernel `(columns, start, end, resources, lookups, keys, args, commands, tick)` that records the shapes it assumed (`world/create/kernel.ts`). The runtime applies change filters and passes only passing row ranges. Kernel code references no module bindings, so it can move to a worker chunk. + - Registration checks those shapes against the schemas. + - In 1.1, kernels run on the main thread. +8. **`dispose`,** and the particle sample as an end-to-end test. + +## 1.2 Workers + +- **Pool:** sized from `navigator.hardwareConcurrency` minus the main thread, and only with shared memory. The worker entry script (`worker.ts`) lives in this folder. Without cross-origin isolation, arrays use plain `ArrayBuffer`s and everything runs on the main thread, since growing unshared memory would detach every view. +- **Kernel modules:** the plugin emits each kernel's `create` as its own module, an emitted chunk in builds and a virtual module in dev. The description carries its `url`, and the main thread and every worker `import()` it. +- **Registration:** + - Post the shared `Memory` to each worker once. + - Each worker imports the kernel URLs and keeps the kernels by index, along with their column sources, lookups and keys as component ids. + - Registration resolves once every worker has finished loading. +- **Shared state:** + - The spawn cursor and free list live in shared memory, and `reserve` is atomic, so workers spawn real entities. + - After each sync point, workers receive what changed in the layout: new schemas, new or grown archetypes, and the entity table. Messages are ordered, so a worker always has the layout before the tasks that use it. Shared memory doesn't detach as it grows, so nothing else is reposted. +- **Dispatch:** + - Within a tier, kernels start first: an archetype of at least `minimumChunk` rows is split across workers, and smaller ones run on the main thread. WASM kernels claim pieces of about an eighth of a worker's share from a counter in shared memory, so performance and efficiency cores finish together; JS kernels take even shares. Then non-kernel systems run on the main thread while the workers work. + - Each worker applies the change filter to its own chunk. + - Each system has its own command buffer per run. A worker returns its chunk's commands with its reply, naming components by id. Buffers merge in (tier order, chunk order), so results don't depend on timing. Encoding commands into shared memory is phase 2. + - A failing system or chunk rejects the run after its tier finishes. Later tiers are skipped, and the commands so far still apply. +- **Tests:** a world with zero workers and with three gives identical results, including commands and lookups issued from workers (`workers.browser.test.ts`, chromium). `dispose` terminates every worker. + +## 1.3 WASM + +- **Layout:** a linear component is one interleaved column per archetype, in its schema's layout: `storage` (WGSL storage-buffer rules), `vertex` (tight, 4-byte aligned), or by default tight for the CPU. A GPU layout is checked at registration. Kernels address a field as `row * stride + offset`. +- **The compiler lowers each kernel once** and prints both the JS kernel and a `Program` IR from it. The IR is `null` for kernels with commands or lookups, which keep the JS kernel. +- **Registration generates WASM** from the IR and the schemas' storage types: f64 arithmetic with typed-array store conversion, so scalar WASM is bit-identical to JS. It compiles once, and every worker instantiates the module against the shared `Memory`. +- **SIMD**, in f32 lanes at storage precision: + - *Across rows*, four at a time, when every column is a dense f32 scalar. + - *Across rows of whole elements*, four at a time as `stride / 4` full vectors, when every store fuses into whole-element writes of components that read only same-stride components lane for lane (`p = p + v` on tight vec2 or vec3). The per-row loop takes the tail. + - *Within a row* otherwise: consecutive snapshot loads of a component's leaves become one vector load, and stores to its leaves whose values match lane for lane become one f32x4 operation and store. Loads and stores cover only the element's lanes. + - Anything else runs as scalar WASM, in the same row. +- **Tests:** scalar WASM is bit-identical to JS; SIMD is within storage precision of JS; 0 and N workers stay identical with SIMD, since chunks start on 16-row boundaries. + +## Deferred + +Builtins, finalizers, rules, the ESLint rule, and GPU. diff --git a/packages/ecs/src/implementation/world/plan.ts b/packages/ecs/src/implementation/world/plan.ts new file mode 100644 index 000000000..bdb900e6e --- /dev/null +++ b/packages/ecs/src/implementation/world/plan.ts @@ -0,0 +1,71 @@ +/** + * A sync point's plan: each touched entity's net change, as reusable parallel arrays rather than an object per entity. + * `to` is where it ends up; its values are command ranges, applied in issue order so later values win. Each world has + * its own, since a sync point can wait for the GPU between planning and applying. + */ +export type Plan = { + count: number; + /** Entries made by spawns; each consumed one reservation. */ + spawned: number; + /** Commands that did nothing, since their entity wasn't alive. */ + dropped: number; + entity: Float64Array; + /** Archetype id, or -1 for an entity spawned in this sync point. */ + from: Int32Array; + to: Int32Array; + despawned: Uint8Array; + /** While applying: the row an entity leaves in `from`, kept current as rows swap into holes; -1 for none. */ + row: Int32Array; + /** While applying: the pair of archetypes a moving entry is in, by index into the pairs; -1 for one staying. */ + pair: Int32Array; + /** While applying: the pairs of source and target archetype entries move between, by key, with their entries in order. */ + readonly pairs: Pairs; + /** The first command range: queued buffer, start and end. Most entities have one. */ + buffer: Int32Array; + start: Int32Array; + end: Int32Array; + /** Further ranges, as buffer, start, end triples. */ + readonly more: Map; + /** Components removed and added again here, so their `added` tick is new although `from` had them. */ + readonly fresh: Map; + /** Where each entity index's entry is, or -1; reset after each sync point. */ + slots: Int32Array; +}; + +/** + * Source and target archetype pairs, reused across sync points: `from[p]` (-1 for spawns) and `to[p]`, the number of + * entries moving between them, and where they start in `order`, which lists every moving entry by pair, in entry order. + * When each pair's entries are consecutive entries, as a system's spawns or moves from one archetype are, each pair + * starts at its first entry and `order` lists entries in order. + */ +export type Pairs = { + readonly byKey: Map; + readonly from: number[]; + readonly to: number[]; + readonly count: number[]; + readonly start: number[]; + /** Entries that leave a row, which is removed after the pairs arrive. */ + leaving: number; + order: Int32Array; +}; + +const initialEntries = 64; + +export const createPlan = (): Plan => ({ + count: 0, + spawned: 0, + dropped: 0, + entity: new Float64Array(initialEntries), + from: new Int32Array(initialEntries), + to: new Int32Array(initialEntries), + despawned: new Uint8Array(initialEntries), + row: new Int32Array(initialEntries), + pair: new Int32Array(initialEntries), + pairs: { byKey: new Map(), from: [], to: [], count: [], start: [], leaving: 0, order: new Int32Array(initialEntries) }, + buffer: new Int32Array(initialEntries), + start: new Int32Array(initialEntries), + end: new Int32Array(initialEntries), + more: new Map(), + fresh: new Map(), + slots: new Int32Array(0), +}); diff --git a/packages/ecs/src/implementation/world/platforms.test-helper.ts b/packages/ecs/src/implementation/world/platforms.test-helper.ts new file mode 100644 index 000000000..284694f64 --- /dev/null +++ b/packages/ecs/src/implementation/world/platforms.test-helper.ts @@ -0,0 +1,67 @@ +/// +import type { ToWorker } from './protocol.js'; +import { browserPort, nodePort, platformWorkers, type WorkerPlatform, type WorkerPort } from './worker-platform.js'; + +/** The platform's workers, counting what the world does with them: workers started and ended, and messages by kind. */ +export type Counting = { + readonly platform: WorkerPlatform; + /** Every port started, in order. */ + readonly ports: readonly WorkerPort[]; + readonly started: () => number; + readonly terminated: () => number; + /** Messages of this kind posted to any worker so far. */ + readonly posted: (kind: ToWorker["kind"]) => number; + /** Completions so far whose task wrote past its command ring's size. */ + readonly spilled: () => number; +}; + +export const countingWorkers = (base: WorkerPlatform | null = platformWorkers()): Counting => { + if (base === null) throw new Error("This platform has no workers."); + const ports: WorkerPort[] = []; + const posted = new Map(); + let terminated = 0; + let spilled = 0; + const start = (): WorkerPort | null => { + const port = base.start(); + if (port === null) return null; + const counted: WorkerPort = { + ...port, + post: (message, transfer) => { + posted.set(message.kind, (posted.get(message.kind) ?? 0) + 1); + port.post(message, transfer); + }, + onMessage: handler => port.onMessage(message => { + if (message.kind !== "tables" && message.spill !== null) spilled++; + handler(message); + }), + terminate: () => { + terminated++; + return port.terminate(); + }, + }; + ports.push(counted); + return counted; + }; + return { + platform: { count: base.count, start }, + ports, + started: () => ports.length, + terminated: () => terminated, + posted: kind => posted.get(kind) ?? 0, + spilled: () => spilled, + }; +}; + +/** `count` of the platform's workers. */ +export const startWorkers = (count: number, platform: WorkerPlatform | null = platformWorkers()): WorkerPort[] => Array.from({ length: count }, () => { + const port = platform?.start() ?? null; + if (port === null) throw new Error("This platform has no workers."); + return port; +}); + +/** A worker whose script doesn't exist, so it fails to load. */ +export const missingWorker = (): WorkerPort => { + if (typeof Worker === "function") return browserPort(new Worker("/@missing/worker.js", { type: "module" })); + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + return nodePort(new threads.Worker(new URL("./missing-worker.js", import.meta.url))); +}; diff --git a/packages/ecs/src/implementation/world/pool-size.cases.ts b/packages/ecs/src/implementation/world/pool-size.cases.ts new file mode 100644 index 000000000..e554aa023 --- /dev/null +++ b/packages/ecs/src/implementation/world/pool-size.cases.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./pool-size.fixture.js"; + +/** The `workers` resource sizes the pool: the cases, over the fixture built for node or from source. */ +export const poolSizeCases = (load: () => Promise) => describe("the workers resource", () => { + it("is null by default, the platform's choice: a worker per hardware thread but the world's", async () => { + const fixture = await load(); + const platform = fixture.platformCount(); + expect(platform).toBe(Math.max(1, navigator.hardwareConcurrency - 1)); + expect(await fixture.byDefault()).toEqual({ resource: null, started: platform }); + }); + it("honors a number, and null again gives the platform's choice", async () => { + const fixture = await load(); + expect((await fixture.resized(1, [null])).started).toEqual([1, 1 + fixture.platformCount()]); + }); + it("sizes the kernel pool at the next sync point, with the same results", async () => { + const fixture = await load(); + const { started, values } = await fixture.resized(2, [3, 0]); + expect(started).toEqual([2, 5, 5]); + expect(values.slice(0, 4)).toEqual([0, 8, 16, 24]); + expect(values[4095]).toBe(4095 * 8); + }); +}); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts new file mode 100644 index 000000000..1ba1129b6 --- /dev/null +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -0,0 +1,66 @@ +import { Component } from "../../interface/component/component.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { Observe } from "../../observe/index.js"; +import { countingWorkers } from "./platforms.test-helper.js"; + +/** Scenarios of the `workers` resource sizing the pool, run built in node and from source in chromium. */ + +const value = Symbol("poolSizeTest/value"); +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + } +} +Component.register(value, { type: { type: "number", precision: 1 } }); + +const seed = System.create({ name: "poolSizeTest/seed" }, ({ commands }) => { + for (let i = 0; i < 4096; i++) commands.spawn([value])({ [value]: i }); +}); +const double = System.create({ name: "poolSizeTest/double", kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); +const resize = System.create({ name: "poolSizeTest/resize", input: { type: "object", properties: { size: { anyOf: [{ type: "integer" }, { type: "null" }] } } }, resources: { write: [workers] } }, ({ input, resources }) => { + resources[workers] = input.size; +}); + +/** The platform's pool size: a worker per hardware thread but the world's. */ +export const platformCount = (): number => countingWorkers().platform.count; + +/** With the resource at its default: its value, and the workers started by the first kernel. */ +export const byDefault = async (): Promise<{ readonly resource: number | null; readonly started: number }> => { + const counting = countingWorkers(); + const world = createWorld({}, { minimumChunk: 64, pool: counting.platform }); + try { + const resource = await Observe.toPromise(Observe.resource(world, workers)); + await world.run(seed); + await world.run(double); + return { resource, started: counting.started() }; + } + finally { + await world.dispose(); + } +}; + +/** + * Starting at `initial`, then set to each of `sizes` with a kernel run after each: workers started so far after each + * step, and the values the kernels doubled. + */ +export const resized = async (initial: number, sizes: readonly (number | null)[]): Promise<{ readonly started: readonly number[]; readonly values: readonly (number | null)[] }> => { + const counting = countingWorkers(); + const world = createWorld({ [workers]: initial }, { minimumChunk: 64, pool: counting.platform }); + try { + await world.run(seed); + await world.run(double); + const started = [counting.started()]; + for (const size of sizes) { + await world.run(resize, { size }); + await world.run(double); + started.push(counting.started()); + } + return { started, values: await readEach(world, await select(world, { required: [value] }), value) }; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/pool.cases.ts b/packages/ecs/src/implementation/world/pool.cases.ts new file mode 100644 index 000000000..51f4af406 --- /dev/null +++ b/packages/ecs/src/implementation/world/pool.cases.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./pool.fixture.js"; + +/** The pool over its ports: the cases, over the fixture built for node or from source. */ +export const poolCases = (load: () => Promise) => describe("createPool", () => { + it("fails tasks on a worker that couldn't load, rather than hang", async () => { + const fixture = await load(); + const { message, same } = await fixture.unloadable(); + expect(message).toContain("failed"); + // Later tasks fail immediately too. + expect(same).toBe(true); + }, 30000); + + it("releases its control state when terminated, so pools of new sizes don't grow what the arena holds", async () => { + const fixture = await load(); + for (const [before, after] of await fixture.released()) { + expect(before).toBeGreaterThan(0); + expect(after).toBe(0); + } + }, 30000); + + it("fails every task of a worker whose memory is shorter than the length the world stored", async () => { + const fixture = await load(); + const [before, during, after] = await fixture.shortMemory(); + expect(before).toBeUndefined(); + expect(during).toContain("short of"); + // The worker stays broken, since views it made may be stale. + expect(after).toContain("short of"); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/pool.fixture.ts b/packages/ecs/src/implementation/world/pool.fixture.ts new file mode 100644 index 000000000..f1f682105 --- /dev/null +++ b/packages/ecs/src/implementation/world/pool.fixture.ts @@ -0,0 +1,54 @@ +import { createMemory, pageBytes, pagesWord, type Memory } from "./memory.js"; +import { createPool } from "./pool.js"; +import { idleKernel } from "./protocol.js"; +import { missingWorker, startWorkers } from "./platforms.test-helper.js"; + +/** Scenarios of the pool over its ports, run built in node and from source in chromium. */ + +/** Two tasks on a worker that couldn't load: the first's error, and whether the second failed with the same one. */ +export const unloadable = async (): Promise<{ readonly message: string | undefined; readonly same: boolean }> => { + const pool = createPool([missingWorker()], createMemory(true)); + const first = await pool.request(0, { kind: "kernels", kernels: [] }); + const second = await pool.request(0, { kind: "kernels", kernels: [] }); + await pool.terminate(); + return { message: first.error?.message, same: second.error === first.error }; +}; + +/** Bytes the arena holds for pools of 1 to 4 workers, each with every window opened: before and after `terminate`. */ +export const released = async (): Promise => { + const arena = createMemory(true); + /** Bytes allocated and not yet freed. */ + let held = 0; + const counting: Memory = { + ...arena, + allocate: bytes => { held += bytes; return arena.allocate(bytes); }, + free: (offset, bytes) => { held -= bytes; arena.free(offset, bytes); }, + }; + const results: [number, number][] = []; + for (let size = 1; size <= 4; size++) { + const pool = createPool(startWorkers(size), counting); + // Open each worker's window, so each has used the control lines being released. + await Promise.all(pool.claimed(0, size, { kernel: idleKernel, archetype: -1, start: 0, end: 0, tick: 0, lastRun: 0, scalars: [], partials: -1, partialBytes: 0 }, 1)); + const before = held; + await pool.terminate(); + results.push([before, held]); + } + return results; +}; + +/** + * A worker whose memory is shorter than the length the world stored: its errors before, while and after the length + * is past its buffer. + */ +export const shortMemory = async (): Promise => { + const memory = createMemory(true); + const pool = createPool(startWorkers(1), memory); + const before = (await pool.request(0, { kind: "kernels", kernels: [] })).error?.message; + const pages = memory.memory.buffer.byteLength / pageBytes; + Atomics.store(memory.header, pagesWord, pages + 1); + const during = (await pool.request(0, { kind: "kernels", kernels: [] })).error?.message; + Atomics.store(memory.header, pagesWord, pages); + const after = (await pool.request(0, { kind: "kernels", kernels: [] })).error?.message; + await pool.terminate(); + return [before, during, after]; +}; diff --git a/packages/ecs/src/implementation/world/pool.ts b/packages/ecs/src/implementation/world/pool.ts new file mode 100644 index 000000000..f949cc092 --- /dev/null +++ b/packages/ecs/src/implementation/world/pool.ts @@ -0,0 +1,323 @@ +import { allocateArray, freeArray } from './arrays.js'; +import { emptyBuffer, type CommandBuffer } from './buffer.js'; +import type { Memory } from './memory.js'; +import { maximumPieces, startRanges } from './claims.js'; +import { controlWords, splitCount, done, epoch, lineWords, posted, ringBase, ringCapacity, ringCursor, ringNeeded, ringSize, signal, taskFields, taskWords, type ToWorker, type WorkerError } from './protocol.js'; +import type { MergeTag, Segment } from './command-ring.js'; +import type { WorkerPort } from './worker-platform.js'; + +type Task = Extract; +type Request = Task extends infer T ? T extends Task ? Omit : never : never; + +/** + * A task's commands, plus its error if it failed partway; what it issued before the error still applies. A message + * task's commands and spawns are its segment of its worker's command ring, which the world drains. + */ +export type TaskResult = { readonly commands: CommandBuffer; readonly segment?: Segment; readonly error?: Error; readonly kernels?: number }; + +/** A WASM kernel task: everything is a number, so it travels through shared memory instead of a message. */ +export type FastTask = { + readonly kernel: number; + readonly archetype: number; + readonly start: number; + readonly end: number; + readonly tick: number; + readonly lastRun: number; + readonly scalars: readonly number[]; + /** + * The byte address of the task's first reduction partials, or -1 without reductions; they follow the scalars, and + * count against `maximumScalars`. A worker running claimed pieces folds piece `p` into the partials `p * partialBytes` past it. + */ + readonly partials: number; + readonly partialBytes: number; +}; + +/** + * Workers sharing the world's memory, one per port. Messages to one worker are handled in the order they're posted. + * `claimed` tasks go through a ring in shared memory while workers wait on it in a window, which costs one message to + * open; a message, a broadcast or `close` ends the window, after the tasks already posted. + */ +export type Pool = { + readonly size: number; + readonly broadcast: (message: Exclude) => void; + /** Posts a task to one worker. Never rejects: a failed task or a dead worker resolves with an error. */ + readonly request: (worker: number, message: Request) => Promise; + /** + * Posts one task to each of `workers` workers from `first`, which claim `grain`-row pieces of the task's range: + * each from its own home range first, then from others' (`claims.ts`), so faster workers take more and a worker + * mapped to the same range each tier finds its rows in cache. With no room for claims, splits the range evenly. + */ + readonly claimed: (first: number, workers: number, task: FastTask, grain: number) => Promise[]; + /** How many workers were posted a task since the last call, which starts the count again. */ + readonly takePosted: () => number; + readonly close: () => void; + /** + * At a sync point, once its segments are drained and while no task runs: empties every command ring, growing each + * one a task overflowed to what its tasks needed. + */ + readonly rewind: () => void; + /** + * Ends the workers, through their ports, then releases the pool's control state to the arena once every worker that waited on it has + * left its window, as each acknowledges a message. One that doesn't within `releaseTimeout`, or that died with a + * window open, may still touch it, so it's kept. + */ + readonly terminate: () => Promise; +}; + +/** How long `terminate` waits for workers to leave their windows before keeping the control state. */ +const releaseTimeout = 1000; +/** A command ring's first size in bytes; a sync point grows it to what a run's tasks needed. */ +const initialRingBytes = 1 << 16; + +const errorOf = ({ name, message, stack }: WorkerError): Error => Object.assign(new Error(message), { name, stack }); + +/** A task waiting for its completion, with the merge tag of a run's segment, or null for one that writes none. */ +type Pending = { readonly settle: (result: TaskResult) => void; readonly tag: MergeTag | null }; + +type Slot = { + readonly port: WorkerPort; + readonly pending: Map; + /** Set once the worker fails to load, crashes or exits; it gets no more tasks. */ + dead: Error | null; +}; + +type Block = { readonly offset: number; readonly bytes: number }; + +/** Zeroed Int32 words starting on a cache line, so a block of whole lines shares none with other arrays; `blocks` records the block. */ +const allocateLines = (arena: Memory, words: number, blocks: Block[]): Int32Array => { + const lineBytes = lineWords * 4; + const bytes = words * 4 + lineBytes; + const offset = arena.allocate(bytes); + blocks.push({ offset, bytes }); + return new Int32Array(arena.memory.buffer, Math.ceil(offset / lineBytes) * lineBytes, words).fill(0); +}; + +export const createPool = (ports: readonly WorkerPort[], arena: Memory): Pool => { + const { memory } = arena; + const size = ports.length; + let nextTask = 0; + const lines: Block[] = []; + const control = allocateLines(arena, size * controlWords, lines); + const statuses = allocateLines(arena, size * ringSize, lines); + const payloads = allocateArray(arena, Float64Array, size * ringSize * taskWords); + const claims = allocateLines(arena, splitCount * size * lineWords, lines); + /** Splits' claim slots no split in flight uses: one is reused only once every task of its split is done. */ + const freeSplits = Array.from({ length: splitCount }, (_, split) => split); + const open = new Array(size).fill(false); + /** Whether each worker was ever given a window or a run, and so may touch the control state. */ + const windowed = new Array(size).fill(false); + const sent = new Array(size).fill(0); + /** Writes held back by a full ring, chained so they stay in order. */ + const waiting = new Array(size).fill(0); + const chains = Array.from({ length: size }, (): Promise => Promise.resolve()); + /** Each worker's command ring, in the memory. */ + const rings: Block[] = Array.from({ length: size }, () => { + const bytes = initialRingBytes; + return { offset: arena.allocate(bytes), bytes }; + }); + const placeRing = (worker: number): void => { + const base = worker * controlWords; + Atomics.store(control, base + ringBase, rings[worker].offset); + Atomics.store(control, base + ringCapacity, rings[worker].bytes); + Atomics.store(control, base + ringCursor, 0); + Atomics.store(control, base + ringNeeded, 0); + }; + for (let worker = 0; worker < size; worker++) placeRing(worker); + /** Whether a task wrote into a command ring since the last rewind. */ + let written = false; + /** The workers posted a task since `takePosted` last ran, and how many. */ + const postedTo = new Uint8Array(size); + let postedCount = 0; + const markPosted = (worker: number): void => { + if (postedTo[worker] !== 0) return; + postedTo[worker] = 1; + postedCount++; + }; + const kill =(slot: Slot, error: Error): void => { + slot.dead ??= error; + for (const { settle } of slot.pending.values()) settle({ commands: emptyBuffer, error: slot.dead }); + slot.pending.clear(); + }; + const slots: Slot[] = ports.map((port, worker) => { + const slot: Slot = { port, pending: new Map(), dead: null }; + port.onMessage(message => { + const pending = slot.pending.get(message.task); + slot.pending.delete(message.task); + if (pending === undefined) return; + if (message.kind === "tables") return pending.settle({ commands: emptyBuffer, kernels: message.kernels }); + const { start, end, spill } = message; + const wrote = end > start || spill !== null; + if (wrote) written = true; + // The ring moves only at a rewind, which waits for every task. + const segment = wrote && pending.tag !== null ? { bytes: spill ?? new Uint8Array(memory.buffer, rings[worker].offset + start, end - start), tag: pending.tag } : undefined; + pending.settle(message.kind === "done" ? { commands: emptyBuffer, segment } : { commands: emptyBuffer, segment, error: errorOf(message.error) }); + }); + port.onDeath(error => kill(slot, error)); + port.post({ kind: "memory", memory, header: arena.header.byteOffset, control: control.byteOffset + worker * controlWords * 4 }); + return slot; + }); + const wake = (worker: number): void => { + Atomics.add(control, worker * controlWords + signal, 1); + Atomics.notify(control, worker * controlWords + signal); + }; + + const close = (): void => { + for (let worker = 0; worker < size; worker++) { + if (!open[worker]) continue; + open[worker] = false; + Atomics.add(control, worker * controlWords + epoch, 1); + wake(worker); + } + }; + + /** Opens a worker's window for the current epoch, after any messages already posted to it. */ + const ensureOpen = (worker: number): void => { + if (open[worker]) return; + open[worker] = true; + windowed[worker] = true; + const current = Atomics.load(control, worker * controlWords + epoch); + slots[worker].port.post({ kind: "window", control: control.byteOffset, statuses: statuses.byteOffset, payloads: payloads.byteOffset, claims: claims.byteOffset, worker, epoch: current }); + }; + + /** One brief spin for all waiters of a batch of tasks, which are short, before waiting without blocking. */ + let spinUntil = 0; + /** Resolves once the worker has done `count` tasks. */ + const whenDone = async (worker: number, count: number): Promise => { + const index = worker * controlWords + done; + while (Atomics.load(control, index) < count) { + if (slots[worker].dead !== null) return; + if (performance.now() < spinUntil) continue; + const current = Atomics.load(control, index); + if (current >= count) return; + const waited = Atomics.waitAsync(control, index, current, 50); + if (waited.async) await waited.value; + } + }; + + const post = (worker: number, task: FastTask, line: number, grain: number, self: number, participants: number): Promise => { + markPosted(worker); + const slot = slots[worker]; + if (slot.dead !== null) return Promise.resolve({ commands: emptyBuffer, error: slot.dead }); + const count = ++sent[worker]; + const ring = (count - 1) % ringSize; + spinUntil = performance.now() + 2; + const write = (): void => { + // A write held back past a close reopens the window, behind the messages posted since. + ensureOpen(worker); + const current = Atomics.load(control, worker * controlWords + epoch); + const base = (worker * ringSize + ring) * taskWords; + payloads.set([task.kernel, task.archetype, task.start, task.end, task.tick, task.lastRun, current, task.scalars.length, line, grain, self, participants], base); + payloads.set(task.partials < 0 ? task.scalars : [...task.scalars, task.partials, task.partialBytes], base + taskFields); + Atomics.store(control, worker * controlWords + posted, count); + wake(worker); + }; + // Written now when there's room, so the worker starts at once; otherwise in order, once the worker frees a slot. + let written: Promise; + if (waiting[worker] === 0 && count - Atomics.load(control, worker * controlWords + done) <= ringSize) { + write(); + written = Promise.resolve(); + } + else { + waiting[worker]++; + written = chains[worker] = chains[worker].then(async () => { + await whenDone(worker, count - ringSize); + write(); + waiting[worker]--; + }); + } + return (async (): Promise => { + await written; + await whenDone(worker, count); + if (slot.dead !== null) return { commands: emptyBuffer, error: slot.dead }; + // The worker records each task's number, negated if it failed, so a reused slot can't hide a failure. + const failed = Atomics.load(statuses, worker * ringSize + ring) === -count; + return failed ? { commands: emptyBuffer, error: new Error("A world worker's kernel failed.") } : { commands: emptyBuffer }; + })(); + }; + + const claimed = (first: number, workers: number, task: FastTask, grain: number): Promise[] => { + const pieces = Math.ceil((task.end - task.start) / grain); + const split = pieces <= maximumPieces ? freeSplits.pop() : undefined; + if (split === undefined) { + const share = Math.ceil((task.end - task.start) / workers / grain) * grain; + // Each share runs as the grain-row pieces it holds, into those pieces' partials, as claims would. + return Array.from({ length: workers }, (_, i) => + post((first + i) % size, { + ...task, start: task.start + i * share, end: Math.min(task.end, task.start + (i + 1) * share), + partials: task.partials < 0 ? -1 : task.partials + (i * share / grain) * task.partialBytes, + }, -1, grain, 0, 0)); + } + const line = split * size; + startRanges(claims.subarray(line * lineWords, (line + workers) * lineWords), workers, pieces); + const results = Array.from({ length: workers }, (_, i) => post((first + i) % size, task, line, grain, i, workers)); + void Promise.all(results).then(() => freeSplits.push(split)); + return results; + }; + + return { + size, + claimed, + takePosted: () => { + const count = postedCount; + if (count !== 0) { + postedTo.fill(0); + postedCount = 0; + } + return count; + }, + broadcast: message => { + close(); + for (const slot of slots) if (slot.dead === null) slot.port.post(message); + }, + close, + rewind: () => { + if (!written) return; + written = false; + for (let worker = 0; worker < size; worker++) { + // A dead worker gets no more tasks, but may still be writing, so its ring is left to it. + if (slots[worker].dead !== null) continue; + const needed = Atomics.load(control, worker * controlWords + ringNeeded); + const ring = rings[worker]; + if (needed > ring.bytes) { + arena.free(ring.offset, ring.bytes); + const bytes = Math.max(needed, 2 * ring.bytes); + rings[worker] = { offset: arena.allocate(bytes), bytes }; + } + placeRing(worker); + } + }, + request: (worker, message) => new Promise(settle => { + close(); + markPosted(worker); + const slot = slots[worker]; + if (slot.dead !== null) return settle({ commands: emptyBuffer, error: slot.dead }); + const task = nextTask++; + // Its command ring is in the control state, so `terminate` waits for it to leave, as for a window. + windowed[worker] = true; + slot.pending.set(task, { settle, tag: message.kind === "run" ? { tick: message.tick, archetype: message.archetype, start: message.start } : null }); + slot.port.post({ ...message, task }); + }), + terminate: async () => { + close(); + // A worker handles messages only outside its window, so its reply means it has left and won't enter again. + const left = slots.map((slot, worker) => !windowed[worker] ? Promise.resolve(true) + : slot.dead !== null ? Promise.resolve(false) + : new Promise(settle => { + const task = nextTask++; + slot.pending.set(task, { settle: result => settle(result.error === undefined), tag: null }); + slot.port.post({ kind: "kernels", kernels: [], task }); + })); + let timer: ReturnType | undefined; + const timeout = new Promise(settle => { timer = setTimeout(() => settle(false), releaseTimeout); }); + const released = await Promise.race([Promise.all(left).then(all => all.every(Boolean)), timeout]); + clearTimeout(timer); + const disposed = new Error("The world is disposed."); + for (const slot of slots) kill(slot, disposed); + await Promise.all(slots.map(slot => slot.port.terminate())); + if (!released) return; + for (const { offset, bytes } of lines) arena.free(offset, bytes); + for (const { offset, bytes } of rings) arena.free(offset, bytes); + freeArray(arena, payloads); + }, + }; +}; diff --git a/packages/ecs/src/implementation/world/port.ts b/packages/ecs/src/implementation/world/port.ts new file mode 100644 index 000000000..3d5df8f2a --- /dev/null +++ b/packages/ecs/src/implementation/world/port.ts @@ -0,0 +1,141 @@ +/** One end of a channel between threads: messages each way, in the order they were posted. */ +export type Channel = { + /** Posts a message, moving the buffers in `transfer`. */ + readonly post: (message: Send, transfer?: ArrayBuffer[]) => void; + /** Sets the one handler for messages. */ + readonly onMessage: (handler: (message: Receive) => void) => void; +}; + +/** + * A thread as another sees it: over a browser `Worker`, a node `worker_threads` worker, or a `MessagePort` another + * thread lent, whose owner reports its death and ends it. + */ +export type Port = Channel & { + /** Sets the one handler for the thread's death: a load failure, a crash or an exit. It's called at most once. */ + readonly onDeath: (handler: (error: Error) => void) => void; + /** Ends the thread; resolves once it's gone, or once its owner has ended it. */ + readonly terminate: () => Promise; +}; + +/** + * A `MessagePort` of either platform, as both use it. A message's data is what the other end posted, typed by the + * channel it crosses, as the platforms' own `MessageEvent` types leave it. + */ +export type Endpoint = { + readonly postMessage: (message: unknown, transfer: ArrayBuffer[]) => void; + readonly addEventListener: (type: "message" | "close", listener: (event: { readonly data?: any }) => void) => void; + readonly start: () => void; + readonly close: () => void; +}; + +/** + * A worker another thread owns, lent to this one: `port` reaches the worker, and `owner` its owner, which reports the + * worker's death on it and ends the worker when asked. `P` is the platform's `MessagePort`, which transfers. + */ +export type Lent

= { readonly port: P; readonly owner: P }; + +/** Between a lent worker's owner and the thread it's lent to. */ +type ToOwner = { readonly kind: "terminate" }; +type FromOwner = { readonly kind: "death"; readonly message: string } | { readonly kind: "terminated" }; + +/** A death to report once: the first error wins, and a handler set after it still hears it. */ +export const createDeath = (): { readonly die: (error: Error) => void; readonly onDeath: (handler: (error: Error) => void) => void } => { + let error: Error | null = null; + let handler: ((error: Error) => void) | null = null; + return { + die: reason => { + if (error !== null) return; + error = reason; + handler?.(reason); + }, + onDeath: next => { + handler = next; + if (error !== null) next(error); + }, + }; +}; + +const noTransfer: ArrayBuffer[] = []; + +/** A `MessagePort` as a channel. */ +export const endpointChannel = (endpoint: Endpoint): Channel => ({ + post: (message, transfer = noTransfer) => endpoint.postMessage(message, transfer), + onMessage: handler => { + endpoint.addEventListener("message", event => handler(event.data)); + endpoint.start(); + }, +}); + +/** + * A worker's own scope as a channel: node's `parentPort`, or a browser worker's `self`. + */ +export const scopeChannel = (): Channel => { + const parent = globalThis.process?.getBuiltinModule?.("node:worker_threads")?.parentPort ?? null; + if (parent !== null) return endpointChannel(parent); + return { + post: (message, transfer) => self.postMessage(message, { transfer }), + onMessage: handler => { self.onmessage = (event: MessageEvent) => handler(event.data); }, + }; +}; + +/** + * The thread a lent worker runs on, as the thread it's lent to sees it: its death is what its owner reports, or either + * port's `close` where the platform has it; `terminate` asks the owner, and closes both ports once it has, or once the + * owner's port closed. + */ +export const lentPort = ({ port, owner }: Lent): Port => { + const channel = endpointChannel(port); + const death = createDeath(); + const control = endpointChannel(owner); + let terminated: (() => void) | null = null; + control.onMessage(message => { + if (message.kind === "death") death.die(new Error(message.message)); + else terminated?.(); + }); + port.addEventListener("close", () => death.die(new Error("A world worker's port closed."))); + // An owner whose thread is gone can't answer; where the platform reports that, its worker is gone with it. + owner.addEventListener("close", () => { + death.die(new Error("A world worker's owner is gone.")); + terminated?.(); + }); + return { + ...channel, + onDeath: death.onDeath, + terminate: () => new Promise(resolve => { + terminated = () => { + terminated = null; + port.close(); + owner.close(); + resolve(); + }; + control.post({ kind: "terminate" }); + }), + }; +}; + +/** + * On the owner's thread: serves a lent worker's owner end, forwarding the worker's death and ending it when the + * thread it's lent to asks, or when `terminate` is called here, which reports it as a death there. + */ +export const serveLent = (worker: Pick, "onDeath" | "terminate">, owner: Endpoint): { readonly terminate: () => Promise } => { + const control = endpointChannel(owner); + let gone = false; + worker.onDeath(error => { + if (gone) return; + gone = true; + control.post({ kind: "death", message: error.message }); + }); + const end = async (): Promise => { + await worker.terminate(); + if (gone) return; + gone = true; + control.post({ kind: "death", message: "A world worker was terminated by its owner." }); + }; + control.onMessage(async () => { + gone = true; + await worker.terminate(); + control.post({ kind: "terminated" }); + owner.close(); + }); + return { terminate: end }; +}; diff --git a/packages/ecs/src/implementation/world/ports.cases.ts b/packages/ecs/src/implementation/world/ports.cases.ts new file mode 100644 index 000000000..bf96b8beb --- /dev/null +++ b/packages/ecs/src/implementation/world/ports.cases.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./ports.fixture.js"; + +/** Workers that die, are lent, or serve a world on a worker: the cases, over the fixture built for node or from source. */ +export const portsCases = (load: () => Promise) => describe("thread ports", () => { + // A browser `Worker` ended from outside reports nothing, so there it's killed by the owner that lent it. + const ways: readonly ("exit" | "owner")[] = typeof Worker === "function" ? ["owner"] : ["exit", "owner"]; + for (const by of ways) for (const wasm of [true, false]) { + it(`fail the run whose ${wasm ? "WASM" : "JS"} task's worker is killed from outside mid-task, by ${by === "exit" ? "ending it" : "its owner"}, and every later task on it`, async () => { + const fixture = await load(); + const { first, second, disposed } = await fixture.killedMidTask(by, wasm); + // A lent worker's death is what its owner reports, or its port's close where the platform has it, whichever comes first. + const death = by === "exit" ? /exited/ : /terminated by its owner|port closed/; + expect(first.length).toBeGreaterThan(0); + for (const message of first) expect(message).toMatch(death); + expect(second.length).toBeGreaterThan(0); + for (const message of second) expect(message).toMatch(death); + expect(disposed).toBe(true); + }, 30000); + } + + it("serve a world on a worker as its nested workers, with the main thread's results", async () => { + const fixture = await load(); + const alone = await fixture.simulate(0, false); + const nested = await fixture.nested(); + expect(nested.rows).toEqual(alone.rows); + expect(nested.tasks).toBeGreaterThanOrEqual(12 * 3); + }, 30000); + + it("serve a world on a worker as workers the main thread lent it, with the main thread's results", async () => { + const fixture = await load(); + const alone = await fixture.simulate(0, false); + const lent = await fixture.lent(); + expect(lent.rows).toEqual(alone.rows); + expect(lent.tasks).toBeGreaterThanOrEqual(12 * 3); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/ports.fixture.ts b/packages/ecs/src/implementation/world/ports.fixture.ts new file mode 100644 index 000000000..90e9d1776 --- /dev/null +++ b/packages/ecs/src/implementation/world/ports.fixture.ts @@ -0,0 +1,107 @@ +/// +import { Component } from "../../interface/component/component.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { lendBrowserWorkers, lendNodeWorkers, lentWorkers, platformWorkers, type WorkerPlatform } from "./worker-platform.js"; +import type { Lent } from "./port.js"; +import { simulate } from "./workers.fixture.js"; +import { runReported } from "./reports.test-helper.js"; + +/** Scenarios of workers that die, are lent, or serve a world on a worker, run built in node and from source in chromium. */ + +const value = Symbol("portsTest/value"); +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + } +} +Component.register(value, { type: { type: "number" } }); + +const seed = System.create({ name: "portsTest/seed" }, ({ commands }) => { + for (let i = 0; i < 100_000; i++) commands.spawn([value])({ [value]: i }); +}); + +/** Long enough per row that a task over 100,000 rows is still running when its worker is killed. */ +const heavy = System.create({ name: "portsTest/heavy", kernel: "cpu", components: { write: [value] } }, row => { + let total = row[value]; + for (let k = 0; k < 2000; k++) total = total * 0.5 + k; + row[value] = total; +}); + +/** Lends `count` of the platform's workers from this thread, as the main thread lends them to a world on a worker. */ +const lend = (count: number) => typeof Worker === "function" ? lendBrowserWorkers(count) : lendNodeWorkers(count); + +/** + * A heavy kernel's run on one worker, killed from outside the pool right after its task is posted: by ending the + * worker itself (`"exit"`, where the platform reports it) or by the owner that lent it (`"owner"`). The failures of + * that run and of the next, whose task can only fail, and whether `dispose` then finishes. + */ +export const killedMidTask = async (by: "exit" | "owner", wasm: boolean): Promise<{ readonly first: readonly string[]; readonly second: readonly string[]; readonly disposed: boolean }> => { + const lending = by === "owner" ? lend(1) : []; + const base = by === "owner" ? lentWorkers(lending.map(each => each.lent)) : platformWorkers(); + if (base === null) throw new Error("This platform has no workers."); + const trigger = wasm ? "window" : "run"; + let killed = false; + const platform: WorkerPlatform = { + count: 1, + start: () => { + const port = base.start(); + if (port === null) return null; + return { + ...port, + post: (message, transfer) => { + port.post(message, transfer); + if (killed || message.kind !== trigger) return; + killed = true; + // From outside the pool: the worker itself, or its owner. + void (by === "owner" ? lending[0].terminate() : port.terminate()); + }, + }; + }, + }; + const world = createWorld({ [workers]: 1 }, { minimumChunk: 64, wasm, pool: platform }); + await world.run(seed); + const first = (await runReported(world, heavy)).failures.map(failure => failure.message); + const second = (await runReported(world, heavy)).failures.map(failure => failure.message); + const disposed = await Promise.race([world.dispose().then(() => true), new Promise(resolve => setTimeout(() => resolve(false), 20000))]); + return { first, second, disposed }; +}; + +/** The host worker's messages: run `simulate` with its own nested workers, or with workers this thread lent it. */ +export type ToHost = { readonly kind: "nested" } | { readonly kind: "lent"; readonly lent: readonly Lent[] }; +export type FromHost = Awaited>; + +const hostFile = "./world-host.fixture.js"; + +/** Starts the host worker, posts it one message with these ports to transfer, and resolves with its reply. */ +const askHost = (message: ToHost, browserPorts: readonly MessagePort[], nodePorts: readonly import("node:worker_threads").MessagePort[]): Promise => new Promise((resolve, reject) => { + if (typeof Worker === "function") { + const host = new Worker(new URL("./world-host.fixture.js", import.meta.url), { type: "module" }); + host.onmessage = (event: MessageEvent) => { host.terminate(); resolve(event.data); }; + host.onerror = event => reject(new Error(event.message)); + host.postMessage(message, [...browserPorts]); + return; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const host = new threads.Worker(new URL(hostFile, import.meta.url)); + host.on("message", (reply: FromHost) => { void host.terminate(); resolve(reply); }); + host.on("error", reject); + host.postMessage(message, [...nodePorts]); +}); + +/** `simulate(3)` on a world on a worker, its pool workers nested in it. */ +export const nested = (): Promise => askHost({ kind: "nested" }, [], []); + +/** `simulate(3)` on a world on a worker, its pool three workers this thread lent it. */ +export const lent = (): Promise => { + if (typeof Worker === "function") { + const lending = lendBrowserWorkers(3); + return askHost({ kind: "lent", lent: lending.map(each => each.lent) }, lending.flatMap(({ lent }) => [lent.port, lent.owner]), []); + } + const lending = lendNodeWorkers(3); + return askHost({ kind: "lent", lent: lending.map(each => each.lent) }, [], lending.flatMap(({ lent }) => [lent.port, lent.owner])); +}; + +/** `simulate` on this thread, for comparison. */ +export { simulate }; diff --git a/packages/ecs/src/implementation/world/ports.type-test.ts b/packages/ecs/src/implementation/world/ports.type-test.ts new file mode 100644 index 000000000..7df355b01 --- /dev/null +++ b/packages/ecs/src/implementation/world/ports.type-test.ts @@ -0,0 +1,14 @@ +/// +import type { MessagePort as NodeMessagePort, Worker as NodeWorker } from "node:worker_threads"; +import type { World } from "../../interface/world/world.js"; +import { join } from "./create.js"; + +// Node's ports are ports too: a worker_threads MessagePort, Worker and parentPort. +declare const port: NodeMessagePort; +declare const worker: NodeWorker; +declare const world: World; +world.share(port); +world.share(worker); +join(port, { threads: ["main"] }); +const parent: NodeMessagePort | null = globalThis.process.getBuiltinModule("node:worker_threads").parentPort; +if (parent !== null) join(parent); diff --git a/packages/ecs/src/implementation/world/prepared-lookup.ts b/packages/ecs/src/implementation/world/prepared-lookup.ts new file mode 100644 index 000000000..aa53bafbb --- /dev/null +++ b/packages/ecs/src/implementation/world/prepared-lookup.ts @@ -0,0 +1,95 @@ +import { createSite, indexLookupOf, type Shape, type Site } from './index-lookups.js'; + +/** Arguments a site passes at most: its key's scalars, flattened from literals. */ +export const maximumArguments = 16; + +/** How many arguments a shape passes. */ +const countOf = (shape: Shape): number => { + let count = 0; + for (const form of shape) { + if (typeof form === "number") count += Math.max(1, form); + else for (const [, operand] of form) count += Math.max(1, operand); + } + return count; +}; + +/** The arguments a call with literals would have passed, built back from the values a shape flattened them into. */ +const argumentsOf = (shape: Shape, values: readonly unknown[]): unknown[] => { + const built: unknown[] = []; + let next = 0; + const operand = (form: number): unknown => { + if (form === 0) return values[next++]; + const tuple = values.slice(next, next + form); + next += form; + return tuple; + }; + for (const form of shape) { + if (typeof form === "number") built.push(operand(form)); + else { + const comparison: { [operator: string]: unknown } = {}; + for (const [operator, count] of form) comparison[operator] = operand(count); + built.push(comparison); + } + } + return built; +}; + +/** + * One index lookup call site of a main-thread body, for code the plugin emits: `find(lookup, ...values)` takes the + * key as `shape` flattened it, so a literal tuple or comparison builds nothing, and returns the site's own list, or row + * for a unique index, reused each time it runs. A function that isn't an index lookup is called as written. + */ +export const prepareLookup = (shape: Shape) => { + const count = countOf(shape); + let last: unknown = null; + let site: Site | null = null; + const sites = new WeakMap(); + /** The site for `lookup`, or null for a function that isn't an index lookup. */ + const siteOf = (lookup: unknown): Site | null => { + const found = indexLookupOf(lookup); + if (found === undefined || typeof lookup !== "function") return null; + const made = sites.get(lookup) ?? createSite(found, shape); + sites.set(lookup, made); + last = lookup; + site = made; + return made; + }; + /** A call of a function that isn't an index lookup, with the arguments a call with literals would have passed. */ + const asWritten = (lookup: unknown, values: readonly unknown[]): unknown => + typeof lookup === "function" ? Reflect.apply(lookup, undefined, argumentsOf(shape, values)) : undefined; + // The commonest counts take exactly their arguments, so a call passes nothing more. + switch (count) { + case 0: return { + find: (lookup: unknown): unknown => { + const current = lookup === last ? site : siteOf(lookup); + return current === null ? asWritten(lookup, []) : current.find(); + }, + }; + case 1: return { + find: (lookup: unknown, a0: unknown): unknown => { + const current = lookup === last ? site : siteOf(lookup); + return current === null ? asWritten(lookup, [a0]) : current.find(a0); + }, + }; + case 2: return { + find: (lookup: unknown, a0: unknown, a1: unknown): unknown => { + const current = lookup === last ? site : siteOf(lookup); + return current === null ? asWritten(lookup, [a0, a1]) : current.find(a0, a1); + }, + }; + case 3: return { + find: (lookup: unknown, a0: unknown, a1: unknown, a2: unknown): unknown => { + const current = lookup === last ? site : siteOf(lookup); + return current === null ? asWritten(lookup, [a0, a1, a2]) : current.find(a0, a1, a2); + }, + }; + default: return { + find: (lookup: unknown, a0?: unknown, a1?: unknown, a2?: unknown, a3?: unknown, a4?: unknown, a5?: unknown, a6?: unknown, a7?: unknown, + a8?: unknown, a9?: unknown, a10?: unknown, a11?: unknown, a12?: unknown, a13?: unknown, a14?: unknown, a15?: unknown): unknown => { + const current = lookup === last ? site : siteOf(lookup); + if (current === null) return asWritten(lookup, [a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15].slice(0, count)); + return current.find(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15); + }, + }; + } +}; diff --git a/packages/ecs/src/implementation/world/prepared-spawn.ts b/packages/ecs/src/implementation/world/prepared-spawn.ts new file mode 100644 index 000000000..6b26d94f7 --- /dev/null +++ b/packages/ecs/src/implementation/world/prepared-spawn.ts @@ -0,0 +1,161 @@ +import type { Commands } from '../../interface/system/commands.js'; +import type { Essential } from '../../interface/component/value.js'; +import type { Entity } from '../../interface/entity/entity.js'; +import { addCommand, pushCommand, pushComponent, type CommandBuffer } from './buffer.js'; +import { commandsTarget, type CommandsTarget } from './commands.js'; +import { listShapeOf, positionalOf, type ListShape, type Positional } from './lists.js'; +import type { Key, State } from './state.js'; +import { archetypeIndexOf } from '../../interface/registry/index.js'; + +type TargetedCommands = Commands & { readonly [commandsTarget]: CommandsTarget }; + +const isTargeted = (commands: Commands): commands is TargetedCommands => commandsTarget in commands; + +/** The values object a site would have built, for calls the site can't take positionally. */ +const valuesOf = (keys: readonly Key[], values: readonly unknown[]): { [key: string | symbol]: unknown } => { + const object: { [key: string | symbol]: unknown } = {}; + keys.forEach((key, k) => { object[key] = values[k]; }); + return object; +}; + +/** What a site resolved for a world and shape. */ +type Planned = { readonly state: State; readonly shape: ListShape }; + +/** A site's plans: by archetype index, for the world that last used it, and by shape, which belongs to one world and is held only while it lives. */ +type Plans

= { readonly byIndex: (P | undefined)[]; readonly byShape: WeakMap }; + +const createPlans =

(): Plans

=> ({ byIndex: [], byShape: new WeakMap() }); + +/** + * A site's plan for a world and list: a kept one, else one `make` makes for its shape and the site's keys, kept from + * then on. `make` is one function per kind of site, so finding a plan allocates nothing. + */ +const planFor =

(plans: Plans

, state: State, list: readonly unknown[], site: readonly Key[], make: (state: State, shape: ListShape, site: readonly Key[]) => P): P => { + const index = archetypeIndexOf(list); + const indexed = index >= 0 ? plans.byIndex[index] : undefined; + // Kept small, so it inlines into the systems spawning and adding: the rest is outlined. + return indexed !== undefined && indexed.state === state ? indexed : planned(plans, state, list, site, make, index); +}; + +/** A site's plan other than one kept by archetype index for this world. */ +const planned =

(plans: Plans

, state: State, list: readonly unknown[], site: readonly Key[], make: (state: State, shape: ListShape, site: readonly Key[]) => P, index: number): P => { + const shape = listShapeOf(state, list); + let plan = plans.byShape.get(shape); + if (plan === undefined) { + plan = make(state, shape, site); + plans.byShape.set(shape, plan); + } + if (index >= 0) plans.byIndex[index] = plan; + return plan; +}; + +/** What a spawn site resolved for a world and list: the spawns of its shape taking the site's values in its key order. */ +type SpawnPlan = Planned & { readonly spawn: Positional }; + +const spawnPlanOf = (state: State, shape: ListShape, site: readonly Key[]): SpawnPlan => ({ state, shape, spawn: positionalOf(state, shape, site) }); + +/** + * One spawn site's staging, for code the compiler emits for `commands.spawn(components)({ ... })` with literal keys: + * `begin(commands, components)` returns the function spawning with the site's values in its key order, generated for + * the shape so its stores are specialized, or null when the spawn must go through `fallback`. + */ +export type PreparedSpawn = { + readonly begin: (commands: Commands, components: readonly Essential[]) => Positional | null; + readonly fallback: (commands: Commands, components: readonly Essential[], values: readonly unknown[]) => Entity; +}; + +/** + * The compiler's hook for a main-thread spawn site with literal keys: the call becomes a per-site function taking the + * values in key order, so no value object is built and nothing is read by key. `keys` is called on first use, after + * the module's key constants are initialized. Commands it didn't make go through `commands.spawn` as written. + */ +export const prepareSpawn = (keys: () => readonly Key[]): PreparedSpawn => { + let resolved: readonly Key[] | null = null; + /** A key named twice means the later value wins, as in the object literal: such sites use `commands.spawn`. */ + let distinct = false; + const plans = createPlans(); + return { + begin: (commands, components) => { + if (resolved === null) { + resolved = keys(); + distinct = new Set(resolved).size === resolved.length; + } + if (!distinct || !isTargeted(commands)) return null; + return planFor(plans, commands[commandsTarget].state, components, resolved, spawnPlanOf).spawn; + }, + // Commands this world didn't make, or keys named twice, which only an untyped values object can carry. + fallback: (commands, components, values) => Reflect.apply(commands.spawn(components), undefined, [valuesOf(resolved ?? keys(), values)]), + }; +}; + +/** + * What an add site resolved for a world and list: its values' component ids, -1 for one the list doesn't name, with + * their starts, and the list's other components with theirs. + */ +type AddPlan = Planned & { readonly ids: readonly number[]; readonly given: readonly unknown[]; readonly missing: readonly number[]; readonly starts: readonly unknown[] }; + +const addPlanOf = (state: State, shape: ListShape, site: readonly Key[]): AddPlan => { + const named = shape.keys.flatMap((key, k) => site.includes(key) ? [] : [k]); + const at = site.map(key => typeof key === "symbol" ? shape.keys.indexOf(key) : -1); + return { + state, + shape, + ids: at.map(k => k < 0 ? -1 : shape.ids[k]), + given: at.map(k => k < 0 ? undefined : shape.starts[k]), + missing: named.map(k => shape.ids[k]), + starts: named.map(k => shape.starts[k]), + }; +}; + +/** + * One add site's command, for code the compiler emits for `commands.add(entity, components, { ... })` with literal + * keys: `begin(commands, entity, components)` appends the add command and returns true, or false when the add must go + * through `fallback`; then `value(k, value)` appends value `k`, `undefined` as its start; `end()` appends the starts of + * the components the site gives no value. + */ +export type PreparedAdd = { + readonly begin: (commands: Commands, entity: Entity, components: readonly Essential[]) => boolean; + readonly value: (k: number, value: unknown) => void; + readonly end: () => void; + readonly fallback: (commands: Commands, entity: Entity, components: readonly Essential[], values: readonly unknown[]) => void; +}; + +/** + * The compiler's hook for a main-thread add site with literal keys: the call becomes a per-site function taking the + * entity and values in key order, so no value object is built and nothing is read by key. Ids are resolved once per + * world and list, so a key that isn't a component commands may add throws before anything is appended, as + * `commands.add` does. Commands it didn't make go through `commands.add` as written. + */ +export const prepareAdd = (keys: () => readonly Key[]): PreparedAdd => { + let resolved: readonly Key[] | null = null; + let distinct = false; + const plans = createPlans(); + let plan: AddPlan | null = null; + let buffer: CommandBuffer | null = null; + return { + begin: (commands, entity, components) => { + if (resolved === null) { + resolved = keys(); + distinct = new Set(resolved).size === resolved.length; + } + if (!distinct || !isTargeted(commands)) return false; + const { state, target } = commands[commandsTarget]; + plan = planFor(plans, state, components, resolved, addPlanOf); + buffer = target.buffer; + pushCommand(buffer, addCommand, entity); + buffer.shapes[buffer.count - 1] = plan.shape.index; + return true; + }, + value: (k, value) => { + const id = plan === null ? -1 : plan.ids[k]; + if (buffer === null || plan === null || id < 0) return; + pushComponent(buffer, id, value === undefined ? plan.given[k] : value); + }, + end: () => { + if (buffer === null || plan === null) return; + const { missing, starts } = plan; + for (let k = 0; k < missing.length; k++) pushComponent(buffer, missing[k], starts[k]); + }, + fallback: (commands, entity, components, values) => Reflect.apply(commands.add, commands, [entity, components, valuesOf(resolved ?? keys(), values)]), + }; +}; diff --git a/packages/ecs/src/implementation/world/program.ts b/packages/ecs/src/implementation/world/program.ts new file mode 100644 index 000000000..5d1c99a5e --- /dev/null +++ b/packages/ecs/src/implementation/world/program.ts @@ -0,0 +1,13 @@ +import type { Finalizer } from '../../interface/finalizer/created.js'; +import type { Rule } from '../../interface/rule/created.js'; +import type { Key } from './state.js'; + +/** + * The components a world must have met before the item can match anything there: all of them, or for a changes + * finalizer any one it watches. Empty when it can match from the start. + */ +export const triggersOf = (item: Rule | Finalizer): { readonly keys: readonly Key[]; readonly any: boolean } => { + if (item.kind === "rule") return { keys: item.declaration.components.has ?? [], any: false }; + if (item.kind === "finalizer") return { keys: [...item.declaration.components.read ?? [], ...item.declaration.components.has ?? []], any: false }; + return { keys: item.declaration.watch ?? [], any: true }; +}; diff --git a/packages/ecs/src/implementation/world/protocol.ts b/packages/ecs/src/implementation/world/protocol.ts new file mode 100644 index 000000000..421476d74 --- /dev/null +++ b/packages/ecs/src/implementation/world/protocol.ts @@ -0,0 +1,127 @@ +import type { Schema } from '../../interface/schema/schema.js'; +import type { Endpoint } from './port.js'; +import type { WasmScalar } from './wasm.js'; +import type { IndexReaderSetup } from './index-readers.js'; + +/** Where a kernel column comes from in any archetype it matches. */ +export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks"; readonly id: number; readonly leaf: number }; + +/** Component ids of one lookup, and for a lookup by entity with `changed`, those it watches. */ +export type LookupSetup = { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly changed: readonly number[] | null }; + +export type KernelSetup = { + readonly index: number; + readonly url: string; + readonly sources: readonly Source[]; + readonly booleans: readonly boolean[]; + readonly strides: readonly number[]; + readonly offsets: readonly number[]; + /** Tick columns nothing observes: they point at a dummy element. */ + readonly muted: readonly boolean[]; + readonly lookups: readonly LookupSetup[]; + /** By lookup through an index: its lookup, how its JS reader reads it, and how many components it watches. */ + readonly indexes: readonly { readonly lookup: number; readonly reader: IndexReaderSetup; readonly watched: number }[]; + /** Component ids, used as `keys` so command values name components by id. */ + readonly keys: readonly number[]; + /** By key index: what each component stored in linear memory starts at, for a spawn leaving it out; null for the rest, whose start the world resolves. */ + readonly starts: readonly unknown[]; + /** The component ids of each list the kernel spawns, in the order its spawner takes values. */ + readonly spawns: readonly (readonly number[])[]; + readonly added: readonly number[]; + readonly changed: readonly number[]; + /** For a kernel reading `up`: each parent column's boolean flag, stride and offset, the same in every archetype. */ + readonly up: { readonly booleans: readonly boolean[]; readonly strides: readonly number[]; readonly offsets: readonly number[] }; + /** Whether the kernel reduces: its fast tasks then carry their partials' address and stride after the scalars. */ + readonly reduces: boolean; + /** The compiled WASM version, which workers instantiate against the shared memory and prefer. */ + readonly wasm: { readonly module: WebAssembly.Module; readonly scalars: readonly WasmScalar[]; readonly imports: readonly string[] } | null; +}; + +/** + * Messages are handled in order, so new components' schemas always arrive before the tasks that use them. Where + * archetypes and the entity table are in the memory, workers read from the layout directory, whose header is at byte + * `header` (`layout-directory.ts`). + */ +export type ToWorker = + /** From a lent worker's owner, first: the port every later message arrives on and every reply goes to. */ + | { readonly kind: "port"; readonly port: Endpoint } + /** The memory, its layout directory's header, and the byte offset of this worker's control words, its command ring's among them. */ + | { readonly kind: "memory"; readonly memory: WebAssembly.Memory; readonly header: number; readonly control: number } + /** Wait on this worker's task ring in shared memory until asked to leave. Offsets are bytes into the memory. */ + | { readonly kind: "window"; readonly control: number; readonly statuses: number; readonly payloads: number; readonly claims: number; readonly worker: number; readonly epoch: number } + /** New components' schemas; none for a value that isn't data. */ + | { readonly kind: "layout"; readonly schemas: readonly (readonly [number, Schema | undefined])[] } + | { readonly kind: "kernels"; readonly task: number; readonly kernels: readonly KernelSetup[] } + /** For tests of what a world holds: how many kernels this worker keeps. */ + | { readonly kind: "tables"; readonly task: number } + | { + readonly kind: "run"; + readonly task: number; + readonly kernel: number; + readonly archetype: number; + readonly start: number; + readonly end: number; + readonly tick: number; + /** Rows pass only if a filtered tick is newer than this; `-1` passes every row. */ + readonly lastRun: number; + readonly resources: readonly unknown[]; + readonly args: unknown; + /** The byte address of this chunk's reduction partials, or -1 for a kernel without reductions. */ + readonly partials: number; + /** A WASM kernel's lookup parameters, and its lookups through indexes', which JS kernels' readers read too. */ + readonly lookups: readonly number[]; + readonly indexes: readonly number[]; + }; + +export type WorkerError = { readonly name: string; readonly message: string; readonly stack?: string }; + +/** + * A task's completion: its segment of the worker's command ring, as start and end bytes past the ring's base, and what + * it wrote past the ring's size, or null (`command-ring.ts`). A failed task's segment holds what it issued before failing, + * which applies, as the main thread's would. + */ +export type FromWorker = + | { readonly kind: "done"; readonly task: number; readonly start: number; readonly end: number; readonly spill: Uint8Array | null } + | { readonly kind: "failed"; readonly task: number; readonly start: number; readonly end: number; readonly spill: Uint8Array | null; readonly error: WorkerError } + | { readonly kind: "tables"; readonly task: number; readonly kernels: number }; + +/** + * Tasks a worker's ring holds, and f64 words per task: twelve fields, then scalars. The fields are kernel, archetype, + * start, end, tick, lastRun, the window epoch, the scalar count, then the split's claims: the line of its first range + * word (-1 for none), the grain, this worker's participant index and the participant count. Without claims the task + * runs `start` to `end`; with them it runs the `grain`-row pieces it claims (`claims.ts`). + */ +export const ringSize = 64; +/** A ring task's kernel that runs nothing: a round trip to the worker and back, as the `wake` benchmark measures. */ +export const idleKernel = -1; +export const taskWords = 32; +export const taskFields = 12; +export const maximumScalars = taskWords - taskFields; +/** + * Int32 words per 128-byte cache line, the largest in use (Apple silicon). Words that different threads write each get + * a line of their own, so a store by one doesn't invalidate the line another is polling. + */ +export const lineWords = 32; +/** Splits in flight with claims the pool holds room for: one range word per worker each, each on its own line. */ +export const splitCount = 256; +/** + * Control words per worker: tasks posted, tasks done, the window epoch, and a signal bumped on every post or close, + * which the worker waits on, so a wake-up between its check and its wait isn't lost. Closing a window bumps the + * epoch; a worker leaves its window when the epoch moves on, and runs only tasks posted in its own window's epoch, + * so a task posted after a close waits until the worker has handled the messages sent before it. + */ +export const posted = 0; +export const done = 1; +export const epoch = 2; +export const signal = 3; +/** + * The worker's command ring: its base and capacity in bytes, which the world sets at sync points; its write cursor past + * the base, which the worker stores at each task's end and the world resets at sync points; and the bytes its tasks + * needed since, spills included, which the next sync point grows the ring to. The base reads as unsigned. + */ +export const ringBase = 4; +export const ringCapacity = 5; +export const ringCursor = 6; +export const ringNeeded = 7; +/** Each worker's control words take a whole line. */ +export const controlWords = lineWords; diff --git a/packages/ecs/src/implementation/world/publish.ts b/packages/ecs/src/implementation/world/publish.ts new file mode 100644 index 000000000..0a41fd42f --- /dev/null +++ b/packages/ecs/src/implementation/world/publish.ts @@ -0,0 +1,28 @@ +import type { Schema } from '../../interface/schema/schema.js'; +import { schemaOf } from '../../interface/registry/index.js'; +import { flushBetween } from './layout-directory.js'; +import type { Pool } from './pool.js'; +import type { State } from './state.js'; + +/** + * Returns a function that brings workers up to date with the shared memory's layout: it flushes the layout directory, + * where workers find archetypes and the entity table, and posts only new components' schemas. Called after every + * sync point and wherever registration changes layouts, so workers see them before the next run's tasks; allocates + * nothing when nothing is new. + */ +export const createPublisher = (state: State, pool: Pool): (() => void) => { + let schemasSent = 0; + return () => { + flushBetween(state.directory, state.bumped, state.archetypes, state.entities, state.resourceEntity, state.counted); + if (schemasSent === state.keys.length) return; + // Workers never touch resources, so their schemas, defaults included, stay on the world's thread. + // A value that isn't data has no schema; workers store it as an object they never touch. + const schemas: (readonly [number, Schema | undefined])[] = []; + for (let id = schemasSent; id < state.keys.length; id++) { + if (!state.resources.has(id)) schemas.push([id, schemaOf(state.keys[id])]); + } + if (schemas.length > 0) pool.broadcast({ kind: "layout", schemas }); + // Only once sent, so a failed broadcast is retried after the next sync point. + schemasSent = state.keys.length; + }; +}; diff --git a/packages/ecs/src/implementation/world/ranges.ts b/packages/ecs/src/implementation/world/ranges.ts new file mode 100644 index 000000000..485ed9269 --- /dev/null +++ b/packages/ecs/src/implementation/world/ranges.ts @@ -0,0 +1,44 @@ +import { chunkShift } from './kernel.js'; + +/** One `added` or `changed` filter: per-row ticks, and the newest tick of each chunk of rows. */ +export type Filter = { readonly ticks: Float64Array; readonly chunks: Float64Array }; + +/** + * Calls `call` for each maximal range of rows in `start` to `end` that passes the change filters: any tick newer + * than `lastRun`. Chunks with no newer tick are skipped without reading their rows. With no filters, or `lastRun` + * below 0, every row passes. + */ +export const forEachPassingRange = (filters: readonly Filter[], lastRun: number, start: number, end: number, call: (start: number, end: number) => void): void => { + if (lastRun < 0 || filters.length === 0) return call(start, end); + let first = -1; + const single = filters.length === 1 ? filters[0].ticks : null; + for (let chunk = start >> chunkShift, last = (end - 1) >> chunkShift; chunk <= last; chunk++) { + const chunkStart = Math.max(start, chunk << chunkShift); + const chunkEnd = Math.min(end, (chunk + 1) << chunkShift); + let candidate = false; + for (let i = 0; i < filters.length && !candidate; i++) candidate = filters[i].chunks[chunk] > lastRun; + if (!candidate) { + if (first >= 0) { + call(first, chunkStart); + first = -1; + } + continue; + } + for (let row = chunkStart; row < chunkEnd; row++) { + let passes: boolean; + if (single !== null) passes = single[row] > lastRun; + else { + passes = false; + for (let i = 0; i < filters.length && !passes; i++) passes = filters[i].ticks[row] > lastRun; + } + if (passes) { + if (first < 0) first = row; + } + else if (first >= 0) { + call(first, row); + first = -1; + } + } + } + if (first >= 0) call(first, end); +}; diff --git a/packages/ecs/src/implementation/world/reads.test-helper.ts b/packages/ecs/src/implementation/world/reads.test-helper.ts new file mode 100644 index 000000000..8d1940a3b --- /dev/null +++ b/packages/ecs/src/implementation/world/reads.test-helper.ts @@ -0,0 +1,29 @@ +import type { Component } from '../../interface/component/component.js'; +import type { ReadonlyValue } from '../../interface/component/value.js'; +import type { Entity } from '../../interface/entity/entity.js'; +import type { Resource } from '../../interface/resource/resource.js'; +import type { ReadonlyValue as ReadonlyResourceValue } from '../../interface/resource/value.js'; +import type { World } from '../../interface/world/world.js'; +import { Observe } from '../../observe/index.js'; + +// Tests' reads: each is the first value of an observer factory of `src/observe`. + +/** One component of an entity, or `null` if it's dead or lacks it. */ +export const read = async (world: World, entity: Entity, component: C): Promise | null> => { + const values: { readonly [K in C]: ReadonlyValue } | null = await Observe.toPromise(Observe.entity(world, entity, { required: [component] })); + return values === null ? null : values[component]; +}; + +/** The listed components of an entity, or `null` if it's dead or lacks any of them. */ +export const readEntity = (world: World, entity: Entity, components: C) => + Observe.toPromise(Observe.entity(world, entity, { required: components })); + +export const readResource = (world: World, name: R): Promise> => Observe.toPromise(Observe.resource(world, name)); + +export const select = (world: World, query: Observe.Query): Promise => Observe.toPromise(Observe.select(world, query)); + +export const count = (world: World, query: Omit, "order">): Promise => Observe.toPromise(Observe.count(world, query)); + +/** One component of each of these entities, `null` for one that's dead or lacks it. */ +export const readEach = (world: World, entities: readonly Entity[], component: C): Promise<(ReadonlyValue | null)[]> => + Promise.all(entities.map(entity => read(world, entity, component))); diff --git a/packages/ecs/src/implementation/world/reductions.cases.ts b/packages/ecs/src/implementation/world/reductions.cases.ts new file mode 100644 index 000000000..a0ffc40dc --- /dev/null +++ b/packages/ecs/src/implementation/world/reductions.cases.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./reductions.fixture.js"; + +/** Reductions fold every row once, deterministically, across workers: the cases, over the fixture built or from source. */ +export const reductionsCases = (load: () => Promise) => describe("reductions across workers", () => { + for (const wasm of [true, false]) { + it(`fold every row exactly once, in the same order each run (${wasm ? "WASM" : "JS"})`, async () => { + const fixture = await load(); + const { rows, weightAt } = fixture; + const runs = await fixture.reduced(wasm); + for (const [count, largest] of runs) { + expect(count).toBe(rows); + expect(largest).toBe(Math.max(...Array.from({ length: rows }, (_, i) => weightAt(i)))); + } + // Each piece folds into its own partial, combined in piece order, so float sums repeat bit for bit. + const sums = runs.map(([, , total]) => total); + expect(new Set(sums).size).toBe(1); + const direct = Array.from({ length: rows }, (_, i) => weightAt(i)).reduce((total, value) => total + value, 0); + expect(Math.abs((sums[0] ?? NaN) - direct)).toBeLessThan(1e-6 * rows); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/reductions.fixture.ts b/packages/ecs/src/implementation/world/reductions.fixture.ts new file mode 100644 index 000000000..62f2f615b --- /dev/null +++ b/packages/ecs/src/implementation/world/reductions.fixture.ts @@ -0,0 +1,65 @@ +import { Component } from "../../interface/component/component.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import { readResource } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +/** Scenarios of reductions across workers, run built in node and from source in chromium. */ + +const weight = Symbol("reductionsBrowserTest/weight"); +const sized = Symbol("reductionsBrowserTest/sized"); +const count = Symbol("reductionsBrowserTest/count"); +const sum = Symbol("reductionsBrowserTest/sum"); +const largest = Symbol("reductionsBrowserTest/largest"); + +declare module "@adobe/ecs" { + export interface Components { + [weight]: { type: number }; + [sized]: { type: true }; + } + export interface Resources { + [count]: { type: number; reduce: "+" }; + [sum]: { type: number; reduce: "+" }; + [largest]: { type: number; reduce: "max" }; + } +} + +Component.register(weight, { type: { type: "number", precision: 1 } }); +Component.register(sized, { type: { const: true } }); +Resource.register(count, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(sum, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(largest, { type: { type: "number", default: 0 }, reduce: "max" }); + +export const rows = 100_000; +export const weightAt = (i: number): number => Math.fround(Math.sin(i * 0.37) * 1000); + +const spawn = System.create({ name: "reductionsBrowserTest/spawn" }, ({ commands }) => { + for (let i = 0; i < rows; i++) { + if (i % 4 === 0) commands.spawn([weight, sized])({ [weight]: weightAt(i), [sized]: true }); + else commands.spawn([weight])({ [weight]: weightAt(i) }); + } +}); + +const reduce = System.create({ name: "reductionsBrowserTest/reduce", kernel: "cpu", components: { read: [weight] }, reduce: { [count]: "+", [sum]: "+", [largest]: "max" } }, (row, { reduce }) => { + reduce[count](1); + reduce[sum](row[weight]); + reduce[largest](row[weight]); +}); + +/** Four runs of the reduction on four workers: each run's count, largest and sum. */ +export const reduced = async (wasm: boolean): Promise => { + const world = createWorld({ [workers]: 4 }, { minimumChunk: 64, wasm }); + try { + await world.run(spawn); + const runs: [number, number, number][] = []; + for (let run = 0; run < 4; run++) { + await world.run(reduce); + runs.push([await readResource(world, count), await readResource(world, largest), await readResource(world, sum)]); + } + return runs; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts new file mode 100644 index 000000000..d0759de2d --- /dev/null +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Vec3 } from "../../interface/math/index.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { readResource } from "./reads.test-helper.js"; +import { definitionOf } from "../../interface/registry/index.js"; +import type { Name } from "../../interface/system/name.js"; +import { createRegistrar } from "./register.js"; +import { createState } from "./state.js"; +import { workers } from "../../interface/resources/workers.js"; +import { runReported } from "./reports.test-helper.js"; + +const position = Symbol("reductionsTest/position"); +const mass = Symbol("reductionsTest/mass"); +const heavy = Symbol("reductionsTest/heavy"); +const total = Symbol("reductionsTest/total"); +const low = Symbol("reductionsTest/low"); +const bits = Symbol("reductionsTest/bits"); +const least = Symbol("reductionsTest/least"); +const any = Symbol("reductionsTest/any"); +const level = Symbol("reductionsTest/level"); +const seen = Symbol("reductionsTest/seen"); +const wrapped = Symbol("reductionsTest/wrapped"); +const summed = Symbol("reductionsTest/summed"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: Vec3 }; + [mass]: { type: number }; + [heavy]: { type: true }; + } + export interface Resources { + [total]: { type: number; reduce: "+" }; + [low]: { type: readonly [number, number, number]; reduce: "min" }; + [bits]: { type: number; reduce: "|" }; + [least]: { type: number; reduce: "min" }; + [any]: { type: boolean; reduce: "|" }; + [level]: { type: number }; + [seen]: { type: number }; + [wrapped]: { type: number; reduce: "+" }; + [summed]: { type: boolean; reduce: "|" }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(mass, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(heavy, { type: { const: true } }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(low, { type: { ...Vec3.schema, default: [0, 0, 0] }, reduce: "min" }); +Resource.register(bits, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF, default: 0 }, reduce: "|" }); +Resource.register(least, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF, default: 0 }, reduce: "min" }); +Resource.register(any, { type: { type: "boolean", default: false }, reduce: "|" }); +Resource.register(level, { type: { type: "number", default: 0 } }); +Resource.register(seen, { type: { type: "number", default: 0 } }); +Resource.register(wrapped, { type: { type: "integer", minimum: -(2 ** 31), maximum: 2 ** 31 - 1, default: 5 }, reduce: "+" }); + +const rows = 3000; +const positionAt = (i: number): Vec3 => [Math.sin(i) * 100, (i % 97) - 50, i / 7]; +const massAt = (i: number): number => (i * 2654435761) >>> 20; + +/** Rows in two archetypes, so a run folds more than one call's partials. */ +const spawn = System.create({ name: "reductionsTest/spawn" }, ({ commands }) => { + for (let i = 0; i < rows; i++) { + if (i % 3 === 0) commands.spawn([position, mass, heavy])({ [position]: positionAt(i), [mass]: massAt(i), [heavy]: true }); + else commands.spawn([position, mass])({ [position]: positionAt(i), [mass]: massAt(i) }); + } +}); + +const kernel = System.create({ name: "reductionsTest/kernel", kernel: "cpu", components: { read: [position, mass] }, reduce: { [total]: "+", [low]: "min", [bits]: "|", [least]: "min" } }, (row, { reduce }) => { + const [x, y, z] = row[position]; + reduce[total](row[mass]); + reduce[total](0.5); + reduce[low]([x, y, z]); + reduce[bits](row[mass]); + reduce[least](row[mass]); +}); + +/** The same reductions, run as written on the main thread. */ +const body = System.create({ name: "reductionsTest/body", components: { read: [position, mass] }, reduce: { [total]: "+", [low]: "min", [bits]: "|", [least]: "min" } }, (row, { reduce }) => { + const [x, y, z] = row[position]; + reduce[total](row[mass]); + reduce[total](0.5); + reduce[low]([x, y, z]); + reduce[bits](row[mass]); + reduce[least](row[mass]); +}); + +const filtered = System.create({ name: "reductionsTest/filtered", kernel: "cpu", components: { read: [mass], has: [heavy] }, resources: { changed: [level] }, reduce: { [total]: "+", [least]: "min" } }, (row, { reduce }) => { + reduce[total](row[mass]); + reduce[least](row[mass]); +}); + +const flags = System.create({ name: "reductionsTest/flags", kernel: "cpu", components: { read: [mass] }, reduce: { [any]: "|" } }, (row, { reduce }) => { + reduce[any](row[mass] > 4000); +}); + +/** Two contributors to `total`, the heavy rows and the light ones, and a reader of the finished fold. */ +const heavyMass = System.create({ name: "reductionsTest/heavy", kernel: "cpu", components: { read: [mass], has: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { + reduce[total](row[mass]); +}); +const lightMass = System.create({ name: "reductionsTest/light", components: { read: [mass], not: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { + reduce[total](row[mass]); +}); +const reader = System.create({ name: "reductionsTest/reader", resources: { read: [total], write: [seen] } }, ({ resources }) => { + resources[seen] = resources[total]; +}); +const frame = System.create({ name: "reductionsTest/frame", systems: [reader, heavyMass, lightMass] }); + +const weigh = System.create({ name: "reductionsTest/weigh", components: { write: [mass], has: [heavy] } }, row => { row[mass] = 1; }); +const kernelChanged = System.create({ name: "reductionsTest/changed", kernel: "cpu", components: { read: [mass], changed: [mass] }, reduce: { [total]: "+" } }, (row, { reduce }) => { + reduce[total](row[mass]); +}); + +const masses = (keep: (i: number) => boolean): number => Array.from({ length: rows }, (_, i) => i).filter(keep).reduce((sum, i) => sum + massAt(i), 0); + +const expected = () => { + const positions = Array.from({ length: rows }, (_, i) => positionAt(i)); + const values = Array.from({ length: rows }, (_, i) => massAt(i)); + return { + total: masses(() => true) + rows * 0.5, + low: [0, 1, 2].map(k => Math.fround(Math.min(...positions.map(p => p[k] ?? NaN)))), + bits: values.reduce((or, value) => or | value, 0) >>> 0, + least: Math.min(...values), + }; +}; + +const results = async (world: ReturnType) => ({ + total: await readResource(world, total), + low: await readResource(world, low), + bits: await readResource(world, bits), + least: await readResource(world, least), +}); + +describe("reductions", () => { + const cases: readonly { readonly label: string; readonly system: typeof kernel | typeof body; readonly wasm: boolean }[] = [ + { label: "JS kernel", system: kernel, wasm: false }, { label: "WASM kernel", system: kernel, wasm: true }, { label: "main-thread body", system: body, wasm: true }, + ]; + for (const { label, system, wasm } of cases) { + it(`fold every row's contributions into their resources (${label})`, async () => { + const world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(spawn); + await world.run(system); + const want = expected(); + const got = await results(world); + // Positions are f32, so their least is too; sums of integers and halves are exact. + expect({ ...got, low: got.low?.map(Math.fround) }).toEqual(want); + // A second run starts again from the operators' identities rather than adding to the fold. + await world.run(system); + expect((await results(world)).total).toBe(want.total); + await world.dispose(); + }); + } + + it("fold every contributor in a run, start each run again, and give readers the finished fold", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + // The reader is listed first, yet runs after both contributors. + await world.run(frame); + expect(await readResource(world, total)).toBe(masses(() => true)); + expect(await readResource(world, seen)).toBe(masses(() => true)); + await world.run(frame); + expect(await readResource(world, total)).toBe(masses(() => true)); + // A run with one contributor holds that contributor's fold alone. + await world.run(heavyMass); + expect(await readResource(world, total)).toBe(masses(i => i % 3 === 0)); + await world.dispose(); + }); + + it("keep the previous value when a contributor fails, or when every contributor skips the run", async () => { + let fail = false; + const fails = System.create({ name: "reductionsTest/fails", components: { read: [mass], has: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { + reduce[total](row[mass]); + if (fail) throw new Error("contributor failed"); + }); + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(fails); + const before = await readResource(world, total); + fail = true; + expect((await runReported(world, fails)).failures.map(failure => failure.message)).toEqual(["contributor failed"]); + expect(await readResource(world, total)).toBe(before); + // A resource filter passes on its first run; after that `level` doesn't change, so its runs are skipped. + await world.run(filtered); + await world.run(lightMass); + await world.run(filtered); + expect(await readResource(world, total)).toBe(masses(i => i % 3 !== 0)); + await world.dispose(); + }); + + it("fold to the identity over no rows, an empty min of integers as the type's extreme", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(filtered); + expect(await readResource(world, total)).toBe(0); + expect(await readResource(world, least)).toBe(0xFFFFFFFF); + await world.dispose(); + }); + + it("fold only the rows a contributor's change filters pass", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(kernelChanged); + expect(await readResource(world, total)).toBe(masses(() => true)); + await world.run(weigh); + await world.run(kernelChanged); + expect(await readResource(world, total)).toBe(rows / 3); + // Nothing changed since: the filter passes no rows, so the fold is empty. + await world.run(kernelChanged); + expect(await readResource(world, total)).toBe(0); + await world.dispose(); + }); + + it("stamp their resources changed only when the fold differs", async () => { + let ran = 0; + const watcher = System.create({ name: "reductionsTest/reader/2", components: { has: [mass] }, resources: { changed: [total] } }, () => { ran++; }); + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(kernel); + await world.run(watcher); + const first = ran; + expect(first).toBe(rows); + await world.run(kernel); + await world.run(watcher); + expect(ran).toBe(first); + await world.dispose(); + }); + + it("store an infinite sum as any write of it, and only an empty min or max as the integer type's extreme", async () => { + const infinite = System.create({ name: "reductionsTest/infinite", components: { has: [mass] }, reduce: { [wrapped]: "+" } }, (_, { reduce }) => { + reduce[wrapped](Infinity); + }); + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(infinite); + expect(await readResource(world, wrapped)).toBe(0); + await world.dispose(); + }); + + it("fold booleans bitwise", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(flags); + expect(await readResource(world, any)).toBe(true); + await world.dispose(); + }); + + it("reject what can't be reduced", async () => { + // @ts-expect-error booleans reduce bitwise + expect(() => Resource.register(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); + // @ts-expect-error a contributor repeats its resource's operator + expect(() => System.create({ name: "reductionsTest/other", components: { has: [mass] }, reduce: { [total]: "max" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total" with "max", but it's declared reduce: "+"`); + // @ts-expect-error only a resource with a reduce trait is reduced + expect(() => System.create({ name: "reductionsTest/other/2", components: { has: [mass] }, reduce: { [level]: "+" } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); + // @ts-expect-error a reduced resource is never written + expect(() => System.create({ name: "reductionsTest/other/3", resources: { write: [total] } }, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); + // @ts-expect-error a contributor doesn't read what it reduces into + expect(() => System.create({ name: "reductionsTest/other/4", components: { has: [mass] }, resources: { read: [total] }, reduce: { [total]: "+" } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + // @ts-expect-error nor filters on it + expect(() => System.create({ name: "reductionsTest/other/5", components: { has: [mass] }, resources: { changed: [total] }, reduce: { [total]: "+" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + }); + + it("leave nothing subscribed to sync points from a registration that failed", () => { + const state = createState(true); + const registrar = createRegistrar(state); + // A declaration the build doesn't see is a kernel it didn't compile, which only a world's registration finds. + const uncompiled = { name: "reductionsTest/other/6", kernel: "cpu", components: { read: [mass] } } as const; + const both = System.create({ name: "reductionsTest/both", systems: [kernel, System.create(uncompiled, row => { void row[mass]; })] }); + const definition = (system: System) => { + const found = definitionOf(system); + if (found === undefined) throw new Error("not a system"); + return found; + }; + expect(() => registrar.register(definition(both))).toThrow(`is a kernel but wasn't compiled`); + expect(state.afterSync.size).toBe(0); + registrar.register(definition(kernel)).rollback(); + expect(state.afterSync.size).toBe(0); + }); +}); diff --git a/packages/ecs/src/implementation/world/reductions.ts b/packages/ecs/src/implementation/world/reductions.ts new file mode 100644 index 000000000..35d37ae19 --- /dev/null +++ b/packages/ecs/src/implementation/world/reductions.ts @@ -0,0 +1,105 @@ +import type { Reduction } from '../../interface/resource/reduction.js'; +import type { Declaration } from '../../interface/system/declaration.js'; +import { isReduction } from '../../interface/registry/reduction.js'; +import type { Layout, Path, View } from '../../interface/registry/layout.js'; +import { stampChanged } from './archetype.js'; +import { readCell, writeCell } from './cells.js'; +import { resourceCell } from './resources.js'; +import { idOf, type Key, type State } from './state.js'; +import { labelOf } from '../../interface/registry/index.js'; + +/** A resource a system reduces into: its operator, and the scalar leaves of its value. */ +export type ReductionTarget = { + readonly key: Key; + readonly operator: Reduction; + readonly leaves: readonly { readonly path: Path; readonly view: View; readonly boolean: boolean }[]; +}; + +const bitwise: ReadonlySet = new Set(["&", "|", "^"]); + + +/** + * The resources a declaration reduces into, with their layouts' leaves. Creating the system checked each against its + * `reduce` trait, which registering the resource checked against its schema, so a throw here is an invariant broken. + */ +export const reductionTargets = (state: State, declaration: Pick): readonly ReductionTarget[] => + reductionTargetsOf(key => state.layouts[idOf(state, key)], declaration); + +/** The resources a declaration reduces into, by `layoutFor`, each resource's layout. */ +export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, { name, reduce }: Pick): readonly ReductionTarget[] => { + if (reduce === undefined) return []; + return Object.getOwnPropertySymbols(reduce).flatMap((key): ReductionTarget[] => { + const operator: unknown = Reflect.get(reduce, key); + if (operator === undefined) return []; + if (!isReduction(operator)) throw new Error(`System "${name}" reduces "${labelOf(key)}" with "${labelOf(operator)}", which isn't an operator.`); + const layout = layoutFor(key); + if (layout?.kind !== "linear") throw new Error(`System "${name}" reduces "${labelOf(key)}", which isn't stored in linear memory.`); + const leaves = layout.leaves.map(leaf => ({ path: leaf.path, view: leaf.view, boolean: leaf.scalar === "boolean" })); + if (leaves.some(leaf => leaf.boolean) && !bitwise.has(operator)) { + throw new Error(`System "${name}" reduces "${labelOf(key)}", which has boolean values, with "${operator}"; booleans reduce with "&", "|" or "^".`); + } + return [{ key, operator, leaves }]; + }); +}; + +/** Each operator's identity, as a fold starts from it, and what an empty fold gives. */ +export const identityOf = (operator: Reduction): number => { + switch (operator) { + case "+": case "|": case "^": return 0; + case "*": return 1; + case "&": return -1; + case "min": return Infinity; + case "max": return -Infinity; + } +}; + +/** One step of a fold, with JS semantics. Partials are f64; a result rounds or wraps once, when it's stored. */ +export const combine = (operator: Reduction, a: number, b: number): number => { + switch (operator) { + case "+": return a + b; + case "*": return a * b; + case "&": return a & b; + case "|": return a | b; + case "^": return a ^ b; + case "min": return Math.min(a, b); + case "max": return Math.max(a, b); + } +}; + +const integerRange: { readonly [V in View]?: readonly [number, number] } = { + u8: [0, 255], i32: [-(2 ** 31), 2 ** 31 - 1], u32: [0, 2 ** 32 - 1], +}; + +/** A folded leaf in its stored form: a boolean, or an empty `min` or `max` of integers as the type's own extreme. */ +const stored = (value: number, operator: Reduction, view: View, boolean: boolean): number | boolean => { + if (boolean) return (value & 1) !== 0; + const range = integerRange[view]; + const empty = (operator === "min" && value === Infinity) || (operator === "max" && value === -Infinity); + return range !== undefined && empty ? (value > 0 ? range[1] : range[0]) : value; +}; + +const withLeaf = (value: unknown, path: Path, leaf: unknown): unknown => { + const [first, ...rest] = path; + if (first === undefined) return leaf; + const copy: unknown = Array.isArray(value) ? [...value] : { ...Object(value) }; + Reflect.set(Object(copy), first, withLeaf(Reflect.get(Object(value), first), rest, leaf)); + return copy; +}; + +const leafOf = (value: unknown, path: Path): unknown => path.reduce((item, key) => Reflect.get(Object(item), key), value); + +/** The value a fold contributes to a leaf: booleans as 0 or 1. */ +export const contributed = (value: unknown, path: Path): number => Number(leafOf(value, path)); + +/** + * Writes a target's folded leaves, `values[offset + i]` for leaf `i`, and stamps the resource changed at `tick` only + * if a stored leaf differs, so readers filtering on it run only when it does. + */ +export const writeReduction = (state: State, target: ReductionTarget, values: ArrayLike, offset: number, tick: number): void => { + const { storage, row } = resourceCell(state, target.key); + const before = readCell(storage, row); + const next = target.leaves.reduce((value, leaf, i) => withLeaf(value, leaf.path, stored(values[offset + i] ?? NaN, target.operator, leaf.view, leaf.boolean)), before); + writeCell(storage, row, next); + const after = readCell(storage, row); + if (target.leaves.some(leaf => !Object.is(leafOf(before, leaf.path), leafOf(after, leaf.path)))) stampChanged(storage, row, tick); +}; diff --git a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts new file mode 100644 index 000000000..4b96f19ae --- /dev/null +++ b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts @@ -0,0 +1,16 @@ +import { Component, System } from "../../index.js"; + +// A module the identity example evaluates twice, as a dev server does after an edit. +export const score = Symbol("reevaluation/score"); + +declare module "@adobe/ecs" { + interface Components { + [score]: { type: number }; + } +} + +Component.register(score, { type: { type: "number" } }); + +export const bump = System.create({ name: "reevaluation/bump", components: { write: [score] } }, row => { + row[score] += 1; +}); diff --git a/packages/ecs/src/implementation/world/reevaluation.test.ts b/packages/ecs/src/implementation/world/reevaluation.test.ts new file mode 100644 index 000000000..3535fd8c6 --- /dev/null +++ b/packages/ecs/src/implementation/world/reevaluation.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "vitest"; +import { System } from "../../index.js"; +import { Observe } from "../../observe/index.js"; +import { bump, score } from "./reevaluation.test-helper.js"; +import { createWorld } from "../index.js"; +import { runReported } from "./reports.test-helper.js"; + +const spawn = System.create({ name: "reevaluation/spawn" }, ({ commands }) => { commands.spawn([score])({ [score]: 0 }); }); + +// A name is a label, so other code may use it too: this isn't 20-score.ts's `bump`, and reports label the two apart. +const broken = System.create({ name: "reevaluation/bump" }, () => { + throw new Error("offline"); +}); + +it("labels systems that share a name by their ids", async () => { + await using world = createWorld(); + const { failures } = await runReported(world, broken); + expect(failures.map(({ name }) => name)).toEqual(["reevaluation/bump (@adobe/ecs/src/implementation/world/reevaluation.test.ts#broken)"]); +}); + +it("matches the keys and systems of a module evaluated again by id", async () => { + await using world = createWorld(); + await world.run(spawn); + await world.run(bump); + // A query makes the module evaluate again, under the same ids, as an edit on a dev server does. + const path = "./reevaluation.test-helper.js?again"; + const again: typeof import("./reevaluation.test-helper.js") = await import(/* @vite-ignore */ path); + // Its key is the same component, and its system the same system, so a composite holds it once. + await world.run(System.create({ name: "reevaluation/both", systems: [bump, again.bump] })); + const [entity] = await Observe.toPromise(Observe.select(world, { required: [again.score] })); + expect((await Observe.toPromise(Observe.entity(world, entity, { required: [again.score] })))?.[again.score]).toBe(2); +}); diff --git a/packages/ecs/src/implementation/world/register.ts b/packages/ecs/src/implementation/world/register.ts new file mode 100644 index 000000000..e88b16553 --- /dev/null +++ b/packages/ecs/src/implementation/world/register.ts @@ -0,0 +1,412 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import { idOfItem, idOfKey, labelOf, requireDefinition, threadOf, type Definition } from '../../interface/registry/index.js'; +import { createRemoteRunner, unplaceable } from './remote-systems.js'; +import { createJoinedObserverRunner, type ObserverTarget } from './joined-observer.js'; +import { createRunner, releaseHoldings, removedRunner, stopHoldings, type Holdings, type Runner } from './runner.js'; +import { isLeaf, reducedBy, scheduleOf, type Schedule } from '../../interface/registry/schedule.js'; +import { idOf, observe, unobserve, type Key, type State } from './state.js'; +import { labelOfItem, orderKeysOf } from '../../interface/registry/index.js'; +import { entityWatchesOf, indexWatchesOf, lookupComponentsOf, orderComponentsOf } from './lookup-keys.js'; + +/** What a registration still has to do asynchronously: add resources, then load kernels; `rollback` undoes it if that fails. */ +/** + * `ticked`: components the new systems filter on or hold GPU copies of, which must keep ticks before they run; + * `resident`: those they hold GPU copies of. + */ +export type Registration = { readonly resources: readonly Key[]; readonly ticked: readonly number[]; readonly resident: readonly number[]; readonly kernels: readonly Runner[]; readonly rollback: () => void }; + +export type Registrar = { + /** Validates and records a system with all its members. Throws without recording anything on failure. */ + readonly register: (system: Definition) => Registration; + /** A registered system's schedule; undefined for one not registered, or holding a system forgotten since. */ + readonly registered: (system: Definition) => Schedule | undefined; + /** A leaf's runner, or one that runs nothing for a leaf not registered, as a system removed during a run is. */ + readonly runnerOf: (leaf: Leaf) => Runner; + /** + * Forgets systems, as removing them does: their schedules, and leaves' runners, which no run calls again once this + * returns. Returns what releases what the runners held, for once no run in progress uses them. + */ + readonly forget: (systems: readonly Definition[]) => () => void; + /** Every system in a tree, the root included. */ + readonly systemsOf: (root: Definition) => readonly Definition[]; + /** A kernel runner's index, as workers know it. */ + readonly kernelIndexOf: (runner: Runner) => number; + /** Every registered kernel runner. */ + readonly kernels: () => readonly Runner[]; + /** Rebuilds the runners of `kernel: "gpu"` systems for the world's current device, or for the CPU without one; returns them to load. */ + readonly placeGpuKernels: () => readonly Runner[]; + /** + * Why a joined thread that imported the systems of ids `imported` can't claim `threads`: a registered system on one + * it didn't import, or one without an id, or naming a key without one, or one that can't run on a joined thread; + * null when every one can run there. + */ + readonly crossing: (threads: readonly string[], imported: ReadonlySet) => string | null; + /** Places each registered system again, as claims were made or released: on its joined thread, or here. */ + readonly place: () => void; + /** + * Registers a forgotten observer leaf again at once, with the runner it had, run again as a new runner would be: + * registering it before found every key and resource it names, so nothing is left to do asynchronously. False when + * it can't, as for one never registered, or whose runner a run in progress may still read as removed. + */ + readonly reregister: (leaf: Leaf) => boolean; + /** For tests of what a world holds: how many schedules, runners and kernel indexes it keeps. */ + readonly sizes: () => { readonly schedules: number; readonly runners: number; readonly kernels: number }; +}; + +/** Every system in a tree, the root included. */ +const systemsOf = (root: Definition): Definition[] => { + const all = new Set(); + const visit = (system: Definition): void => { + if (all.has(system)) return; + all.add(system); + if (!isLeaf(system)) for (const member of system.declaration.systems) visit(requireDefinition(member)); + }; + visit(root); + return [...all]; +}; + +/** Every component and resource a leaf names, so missing schemas throw at registration. */ +const keysOf = (leaf: Leaf): { readonly components: readonly Key[]; readonly resources: readonly Key[] } => { + const { components: access, lookup, resources } = leaf.declaration; + const components = [...access?.read ?? [], ...access?.write ?? [], ...access?.has ?? [], ...access?.not ?? [], ...access?.added ?? [], ...access?.changed ?? []]; + for (const name in lookup) components.push(...lookupComponentsOf(lookup[name])); + const { up } = leaf.declaration; + if (up !== undefined) components.push(up.through, ...up.read ?? []); + components.push(...orderComponentsOf(leaf.declaration)); + return { components, resources: [...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? [], ...resources?.is ?? [], ...resources?.not ?? [], ...reducedBy(leaf)] }; +}; + +/** For a filtered `up` leaf: its parent reference and what it reads of its parent, whose changes also pass a row. */ +const upObserved = (leaf: Leaf): readonly Key[] => { + const { components, resources, up } = leaf.declaration; + const filtered = (components?.added?.length ?? 0) + (components?.changed?.length ?? 0) + (resources?.changed?.length ?? 0) > 0; + return up === undefined || !filtered ? [] : [up.through, ...up.read ?? []]; +}; + +/** + * The components whose changes a leaf's lookups report: the key parts of indexes it looks up through or orders by, + * and what its lookups watch. + */ +const lookupObserved = (leaf: Leaf): readonly Key[] => { + const ordered = orderKeysOf(leaf.declaration); + const looked = indexWatchesOf(leaf.declaration.lookup); + const watched = entityWatchesOf(leaf.declaration.lookup); + return ordered.length + watched.length === 0 ? looked : [...looked, ...watched, ...ordered]; +}; + +/** The components a leaf needs ticks on: those it filters on, and those `upObserved` and `lookupObserved` give. */ +const tickedBy = (leaf: Leaf): readonly Key[] => + [...leaf.declaration.components?.added ?? [], ...leaf.declaration.components?.changed ?? [], ...upObserved(leaf), ...lookupObserved(leaf)]; + +/** The components whose GPU copies a leaf uses: a render system's accessors, and a GPU kernel's columns and what it reads through `up`. */ +const residentBy = (leaf: Leaf): readonly Key[] => { + const { declaration } = leaf; + if (leaf.kind === "render") return Object.values(declaration.shader?.accessors ?? {}); + if (declaration.kernel !== "gpu") return []; + return [...declaration.components?.read ?? [], ...declaration.components?.write ?? [], ...declaration.up?.read ?? []]; +}; + +/** The components whose `changed` stamps a leaf reads, so kernels writing them must keep their stamps. */ +const observedBy = (leaf: Leaf): readonly Key[] => { + const indexed = lookupObserved(leaf); + const observed = [...leaf.declaration.components?.changed ?? [], ...upObserved(leaf)]; + return indexed.length === 0 ? observed : [...new Set([...observed, ...indexed])]; +}; + +/** Every key a leaf names, as `keysOf` gives them, flattened. */ +const allKeysOf = (leaf: Leaf): readonly Key[] => { + const { components, resources } = keysOf(leaf); + return [...components, ...resources]; +}; + +/** + * The joined thread an observer leaf runs on: the one that added it, or the one filling its thread; undefined for one + * that runs here. + */ +const observerTargetOf = (state: State, leaf: Leaf): ObserverTarget | undefined => { + const { proxies, targets } = state.observer; + const target = proxies.get(leaf)?.target ?? (targets.size === 0 ? undefined : targets.get(threadOf(leaf.declaration, "System", leaf.name) ?? "world")); + return target === undefined || target.ended ? undefined : target; +}; + +/** A runner for a leaf where it runs now: on the joined thread filling its thread, or that added it, or here. */ +const placedRunner = (state: State, leaf: Leaf, since = 0): Runner => { + if (leaf.declaration.observer === true) { + const target = observerTargetOf(state, leaf); + return target === undefined ? createRunner(state, leaf, since) : createJoinedObserverRunner(state, leaf, target, since); + } + const thread = threadOf(leaf.declaration, "System", leaf.name); + const channel = thread === null ? undefined : state.threadTasks.get(thread); + return channel === undefined ? createRunner(state, leaf, since) : createRemoteRunner(state, leaf, channel, since); +}; + +/** + * A registered leaf, which lives only as long as its system: its system held weakly, what its runner holds, and the + * components whose changes it counts as watching. + */ +type Entry = { readonly leaf: WeakRef; holdings: Holdings; readonly observed: readonly number[]; released: boolean }; + +/** + * What the finalization registry keeps of a system until it's collected, holding nothing of it: whether it's registered + * now, as one rolled back or forgotten isn't, and for a leaf its entry. + */ +type Registered = { entry: Entry | null; registered: boolean }; + +/** + * What the registrar keeps of a system it registered, by the system: its record in the registry, its schedule, and the + * count of forgettings it was last found whole at. Kept across forgetting, so registering it again records nothing + * more. For a forgotten leaf whose runner registering it again runs again (`Runner.restart`), `parked` is its entry + * then, the runner kept as its leaf's, stopped: adding an observer system again costs no new runner. + */ +type Kept = { readonly held: Registered; schedule: Schedule | null; checked: number; parked: Entry | null }; + +/** + * What a world keeps of each system it registered lives as long as the system's value: schedules and runners by + * system, held weakly, so a system nothing references any more is collected, and with it its runner, its command + * buffers and its `lastRun`. What a runner holds beyond its JS values is released once its system is collected. + */ +export const createRegistrar = (state: State): Registrar => { + const records = new WeakMap(); + const runners = new WeakMap(); + /** Moves on whenever systems are forgotten, so a composite holding one is found to need registering again. */ + let forgettings = 0; + /** Every registered leaf, for what goes over every runner: rare, at placements, device changes and kernel loads. */ + const live = new Set(); + let scheduleCount = 0; + + const release = (entry: Entry): void => { + if (entry.released) return; + entry.released = true; + live.delete(entry); + releaseHoldings(state, entry.holdings); + for (const id of entry.observed) unobserve(state, id); + }; + /** + * Called once a registered system is collected, with its record: a leaf's entry, or null for a composite, and + * whether it's still registered, as a rolled-back one isn't, so no unregister token is kept per registration. + */ + const collected = new FinalizationRegistry(record => { + if (!record.registered) return; + record.registered = false; + scheduleCount--; + if (record.entry !== null) release(record.entry); + }); + + /** Each live leaf with its runner. */ + const eachRunner = (visit: (leaf: Leaf, runner: Runner, entry: Entry) => void): void => { + for (const entry of live) { + const leaf = entry.leaf.deref(); + const runner = leaf === undefined ? undefined : runners.get(leaf); + if (leaf !== undefined && runner !== undefined) visit(leaf, runner, entry); + } + }; + + /** Puts a new runner in an entry's place, releasing what the one it replaces held. */ + const replace = (leaf: Leaf, entry: Entry, old: Runner, runner: Runner): void => { + runners.set(leaf, runner); + state.runnersVersion++; + releaseHoldings(state, old.holdings); + entry.holdings = runner.holdings; + if (runner.holdings.afterSync !== null) state.afterSync.add(runner.holdings.afterSync); + }; + + const register = (root: Definition): Registration => { + const added: { readonly system: Definition; readonly schedule: Schedule; readonly entry: Entry | null; readonly runner: Runner | null }[] = []; + const resources = new Set(); + for (const system of systemsOf(root)) { + if (records.get(system)?.held.registered === true) continue; + const schedule = scheduleOf(system); + if (!isLeaf(system)) { + added.push({ system, schedule, entry: null, runner: null }); + continue; + } + // Creating the system checked its declaration; registering it gives its keys ids in this world. + const keys = keysOf(system); + for (const key of keys.components) idOf(state, key); + for (const key of keys.resources) { + idOf(state, key); + resources.add(key); + } + const runner = parkedRunner(system) ?? placedRunner(state, system); + const entry: Entry = { leaf: new WeakRef(system), holdings: runner.holdings, observed: observedBy(system).map(key => idOf(state, key)), released: false }; + added.push({ system, schedule, entry, runner }); + } + let observing = false; + const kernels: Runner[] = []; + for (const { system, schedule, entry, runner } of added) { + const record = records.get(system); + if (record === undefined) { + const held: Registered = { entry, registered: true }; + records.set(system, { held, schedule, checked: forgettings, parked: null }); + collected.register(system, held); + } + else { + Object.assign(record.held, { entry, registered: true }); + record.schedule = schedule; + record.checked = forgettings; + // Runs that read a removed runner for it read the new one. + if (runner !== null) state.runnersVersion++; + } + scheduleCount++; + if (runner === null || entry === null || !isLeaf(system)) continue; + runners.set(system, runner); + live.add(entry); + if (runner.holdings.afterSync !== null) state.afterSync.add(runner.holdings.afterSync); + if (runner.kernel !== null) kernels.push(runner); + for (const id of entry.observed) { + if (!state.observed.has(id)) observing = true; + observe(state, id); + } + } + // Lists of observer systems read before it registered, holding it as removed, read their runners again. + if (added.some(({ runner }) => runner !== null)) state.runnersVersion++; + // New `changed` filters observe more components, so kernels that muted their stamps must load again. + if (observing) eachRunner((_, runner) => { if (runner.kernel?.stale() === true && !kernels.includes(runner)) kernels.push(runner); }); + const rollback = (): void => { + for (const { system, entry, runner } of added) { + const record = records.get(system); + if (record !== undefined) { + Object.assign(record.held, { entry: null, registered: false }); + record.schedule = null; + } + scheduleCount--; + if (runner === null || entry === null || !isLeaf(system)) continue; + runners.delete(system); + release(entry); + } + state.runnersVersion++; + }; + const resident = added.flatMap(({ system }) => isLeaf(system) ? residentBy(system).map(key => idOf(state, key)) : []); + const ticked = added.flatMap(({ system }) => isLeaf(system) ? tickedBy(system).map(key => idOf(state, key)) : []); + return { resources: [...resources], ticked: resident.length === 0 ? ticked : [...ticked, ...resident], resident, kernels, rollback }; + }; + + /** + * A forgotten leaf's runner, run again as a new runner would be, where it would still run: here. Only once what it + * held was released, so no run in progress still reads it as removed. + */ + const parkedRunner = (leaf: Leaf): Runner | null => { + const record = records.get(leaf); + const entry = record?.parked ?? null; + if (record === undefined || entry === null) return null; + record.parked = null; + const runner = runners.get(leaf); + if (runner === undefined || runner.restart === null || !entry.released || observerTargetOf(state, leaf) !== undefined) return null; + runner.restart(0); + return runner; + }; + + const reregister = (leaf: Leaf): boolean => { + const record = records.get(leaf); + const old = record?.parked ?? null; + if (record === undefined || old === null || record.held.registered) return false; + // Components it starts observing make kernels that muted their stamps load again, asynchronously. + for (const id of old.observed) if (!state.observed.has(id)) return false; + const runner = parkedRunner(leaf); + if (runner === null) return false; + const entry: Entry = { leaf: old.leaf, holdings: runner.holdings, observed: old.observed, released: false }; + record.held.entry = entry; + record.held.registered = true; + record.schedule = scheduleOf(leaf); + record.checked = forgettings; + scheduleCount++; + live.add(entry); + for (const id of entry.observed) observe(state, id); + state.runnersVersion++; + return true; + }; + + const registered = (system: Definition): Schedule | undefined => { + const record = records.get(system); + if (record === undefined || !record.held.registered || record.schedule === null) return undefined; + if (record.checked !== forgettings) { + // A composite holding a system forgotten since registers again, its other members keeping their runners. + for (const tier of record.schedule.tiers) for (const leaf of tier) if (!runners.has(leaf)) return undefined; + record.checked = forgettings; + } + return record.schedule; + }; + + /** A leaf's runner; for one forgotten, as a removed system is, one that runs nothing: stopped, or `removedRunner`. */ + const runnerOf = (leaf: Leaf): Runner => runners.get(leaf) ?? removedRunner; + + const forget = (systems: readonly Definition[]): (() => void) => { + const stopped: Entry[] = []; + for (const system of systems) { + const record = records.get(system); + if (record === undefined || !record.held.registered) continue; + const { entry } = record.held; + Object.assign(record.held, { entry: null, registered: false }); + record.schedule = null; + scheduleCount--; + if (entry === null || !isLeaf(system)) continue; + const runner = runners.get(system); + live.delete(entry); + // No run calls its body again from here, on any thread; what it holds goes once no run uses it. + stopHoldings(entry.holdings); + stopped.push(entry); + if (runner !== undefined && runner.restart !== null && runner.joined === null) record.parked = entry; + else runners.delete(system); + } + forgettings++; + state.runnersVersion++; + state.placement++; + return () => { for (const entry of stopped) release(entry); }; + }; + + const kernels = (): readonly Runner[] => { + const found: Runner[] = []; + eachRunner((_, runner) => { if (runner.kernel !== null) found.push(runner); }); + return found; + }; + + const placeGpuKernels = (): readonly Runner[] => { + const placed: Runner[] = []; + eachRunner((leaf, old, entry) => { + if (leaf.declaration.kernel !== "gpu") return; + const runner = createRunner(state, leaf); + replace(leaf, entry, old, runner); + placed.push(runner); + }); + return placed; + }; + + const crossing = (threads: readonly string[], imported: ReadonlySet): string | null => { + let problem: string | null = null; + eachRunner(leaf => { + if (problem !== null) return; + const thread = threadOf(leaf.declaration, "System", leaf.name); + if (thread === null || !threads.includes(thread)) return; + const label = labelOfItem(leaf); + const id = idOfItem(leaf); + if (id === undefined) problem = `System "${label}" runs on thread "${thread}" and has no id, so a joined thread can't run it; the build gives top-level systems one.`; + else if (!imported.has(id)) problem = `System "${label}" runs on thread "${thread}", which the joined thread claiming it never imported.`; + else { + const unnamed = allKeysOf(leaf).find(key => typeof key !== "symbol" || idOfKey(key) === undefined); + problem = unnamed !== undefined + ? `System "${label}" runs on thread "${thread}" and names "${labelOf(unnamed)}", which has no id, so a joined thread can't name it; the build gives top-level keys one.` + : unplaceable(leaf, thread); + } + }); + return problem; + }; + + const place = (): void => { + eachRunner((leaf, old, entry) => { + if (leaf.declaration.observer === true) { + if (observerTargetOf(state, leaf) === old.joined?.target) return; + replace(leaf, entry, old, placedRunner(state, leaf, old.lastRun())); + return; + } + const thread = threadOf(leaf.declaration, "System", leaf.name); + const channel = thread === null ? undefined : state.threadTasks.get(thread); + if (channel === undefined ? old.remote === null : old.remote?.channel === channel) return; + replace(leaf, entry, old, placedRunner(state, leaf, old.lastRun())); + }); + state.placement++; + }; + + const sizes = () => ({ schedules: scheduleCount, runners: live.size, kernels: state.kernelIndexes.size }); + + return { register, registered, runnerOf, forget, reregister, systemsOf, kernelIndexOf: runner => runner.kernel?.index ?? -1, kernels, placeGpuKernels, crossing, place, sizes }; +}; diff --git a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts new file mode 100644 index 000000000..bae09b4b2 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts @@ -0,0 +1,44 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { entitled, released, type Released } from "./remote-finalizers.test-helper.js"; + +/** + * A thread joining a world through the port it's sent, claiming "remoteFinalizersTest", whose finalizer it imports: it + * tells what its finalizers saw when asked, or spins, without yielding, for a while or for good. + */ + +type Port = MessagePort | NodeMessagePort; +export type ToRemoteFinalizersWorker = + | { readonly kind: "join"; readonly port: Port } + | { readonly kind: "released" } + | { readonly kind: "entitled" } + | { readonly kind: "pause"; readonly milliseconds: number } + | { readonly kind: "spin" }; +export type FromRemoteFinalizersWorker = + | { readonly kind: "joined" } + | { readonly kind: "released"; readonly released: readonly Released[] } + | { readonly kind: "entitled"; readonly entitled: readonly { readonly serial: number; readonly title: string }[] } + | { readonly kind: "spinning" }; + +const channel = scopeChannel(); +channel.onMessage(message => { + if ("protocol" in message) return; + if (message.kind === "join") { + join(message.port, { threads: ["remoteFinalizersTest"] }); + channel.post({ kind: "joined" }); + } + else if (message.kind === "released") channel.post({ kind: "released", released }); + else if (message.kind === "entitled") channel.post({ kind: "entitled", entitled }); + else if (message.kind === "pause") { + channel.post({ kind: "spinning" }); + // Runs nothing meanwhile, finalizers included, then goes on. + const until = performance.now() + message.milliseconds; + while (performance.now() < until) { /* busy */ } + } + else { + channel.post({ kind: "spinning" }); + // Never yields again, from before the world stops anything, so nothing here runs: no finalizer, no acknowledgement. + for (;;) { /* never yields */ } + } +}); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts new file mode 100644 index 000000000..2e3a05c24 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./remote-finalizers.fixture.js"; + +/** Finalizers on a joined worker: the cases, over the fixture built for node or from source. */ +export const remoteFinalizersCases = (load: () => Promise) => describe("finalizers on a joined worker", () => { + it("run once per stop there, as an inline world's do, with its objects and the structs before each sync point applied", async () => { + const fixture = await load(); + const { expected, actual } = await fixture.sameStops(); + expect(actual.counts).toEqual(expected.counts); + expect(actual.stops).toEqual(expected.stops); + }, 30000); + it("hold the indexes of entities they stopped until the worker finalizes them, which it never does once it's ended: they're reused, its stops reported", async () => { + const fixture = await load(); + expect(await fixture.diesHolding()).toEqual({ + held: 3, whileHeld: 0, + failures: [["thread", "remoteFinalizersTest/release", 'Thread "remoteFinalizersTest" is gone, so 3 stops of finalizer "remoteFinalizersTest/release" didn\'t run there.']], + after: 3, + }); + }, 30000); + it("read the strings their stops held, which the stop lists copy, after the world freed and reused their records", async () => { + const fixture = await load(); + expect(await fixture.titledStops()).toEqual(Array.from({ length: 50 }, (_, k) => ({ serial: k, title: `t${k}` }))); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts new file mode 100644 index 000000000..d4dcaf58a --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts @@ -0,0 +1,140 @@ +/// +import type { Entity } from "../../interface/entity/entity.js"; +import { Observe } from "../../observe/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import { indexOf } from "./entities.js"; +import type { FromRemoteFinalizersWorker, ToRemoteFinalizersWorker } from "./remote-finalizers-worker.fixture.js"; +import { despawn, released, retitle, scenario, serial, sorted, spawn, spawnTitled, type Released } from "./remote-finalizers.test-helper.js"; +import { fillReports, runReported } from "./reports.test-helper.js"; + +/** + * Scenarios of finalizers on a joined worker, run built in node and from source in chromium: the same stops as an + * inline world's, and a worker that never yields holding the indexes of entities it hasn't finalized until it's ended. + */ + +type Worker = { readonly ask: (message: ToRemoteFinalizersWorker, transfer?: readonly unknown[]) => Promise; readonly terminate: () => Promise }; + +const workerFile = "./remote-finalizers-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./remote-finalizers-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer = []) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => { if (!("protocol" in event.data)) resolve(event.data); }; + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer = []) => new Promise(resolve => { + const reply = (data: FromRemoteFinalizersWorker): void => { + if ("protocol" in data) return; + worker.off("message", reply); + resolve(data); + }; + worker.on("message", reply); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + }; +}; + +/** The first value an observer gives that `accept` takes. */ +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +/** A world shared with a worker that joins it claiming "remoteFinalizersTest", once it joined. */ +const joined = async (world: CreatedWorld): Promise<{ readonly worker: Worker }> => { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + await worker.ask({ kind: "join", port: port2 }, [port2]); + await until(world.threads, threads => threads.at(-1)?.status === "joined"); + return { worker }; +}; + +/** What the worker's finalizer saw. */ +const releasedThere = async (worker: Worker): Promise => { + const reply = await worker.ask({ kind: "released" }); + return reply.kind === "released" ? reply.released : []; +}; + +/** The scenario's stops, inline and with the joined worker running the finalizer, in order by serial. */ +export const sameStops = async () => { + released.length = 0; + const inline = createWorld({ [workers]: 0 }); + const counts = await scenario(inline, () => released.length); + const expected = sorted(released); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + const seen = await scenario(world, async () => (await releasedThere(worker)).length, counts); + const actual = sorted(await releasedThere(worker)); + await worker.terminate(); + await world.dispose(); + return { expected: { counts, stops: expected }, actual: { counts: seen, stops: actual } }; +}; + +/** + * A worker that never yields, so it acknowledges no stop: the indexes of the entities despawned stay held while it + * lives, and once it's ended they're reused, the stops it never finalized reported. + */ +export const diesHolding = async () => { + const world = createWorld({ [workers]: 0 }); + // Past the runs that spawn reports, whose entities would take the released indexes first. + await fillReports(world); + const { worker } = await joined(world); + await world.run(spawn, { from: 0, count: 4 }); + const stopped: Entity[] = []; + for (const value of [0, 1, 2]) stopped.push(...await Observe.toPromise(Observe.select(world, { required: [serial], where: { [serial]: value } }))); + const despawned = new Set(stopped.map(indexOf)); + /** Live entities at the indexes of those despawned. */ + const reused = async (): Promise => (await Observe.toPromise(Observe.select(world, { required: [serial] }))).filter(entity => despawned.has(indexOf(entity))).length; + await worker.ask({ kind: "spin" }); + await world.run(despawn, { serials: [0, 1, 2] }); + // Spawns while it lives take other indexes. + await world.run(spawn, { from: 10, count: 6 }); + const whileHeld = await reused(); + await worker.terminate(); + await until(world.threads, threads => threads.at(-1)?.status === "gone"); + const report = await runReported(world, spawn, { from: 20, count: 0 }); + await world.run(spawn, { from: 30, count: 6 }); + const after = await reused(); + await world.dispose(); + return { held: despawned.size, whileHeld, failures: report.failures.map(failure => [failure.kind, failure.name, failure.message]), after }; +}; + +/** + * Titled rows despawned while the worker runs nothing, then the world's thread rewriting the titles of others six + * times, so the records the despawned rows' titles were in are freed and reused before the worker finalizes them: + * the titles its finalizer read. + */ +export const titledStops = async () => { + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + await world.run(spawnTitled, { from: 0, count: 50 }); + await worker.ask({ kind: "pause", milliseconds: 300 }); + await world.run(despawn, { serials: Array.from({ length: 50 }, (_, k) => k) }); + await world.run(spawnTitled, { from: 100, count: 50 }); + for (let round = 0; round < 6; round++) await world.run(retitle, { round }); + let seen: readonly { readonly serial: number; readonly title: string }[] = []; + for (let k = 0; k < 200 && seen.length < 50; k++) { + const reply = await worker.ask({ kind: "entitled" }); + seen = reply.kind === "entitled" ? reply.entitled : []; + } + await worker.terminate(); + await world.dispose(); + return [...seen].sort((a, b) => a.serial - b.serial); +}; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts new file mode 100644 index 000000000..872b285f6 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -0,0 +1,160 @@ +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; + +// Keys, systems and a finalizer both threads of the remote finalizers tests import: a finalizer on thread +// "remoteFinalizersTest", which a joined thread fills, reading structs, a constant, that thread's objects and its +// object resource, and what it saw, on the thread it ran on. + +export const serial = Symbol("remoteFinalizersTest/serial"); +export const place = Symbol("remoteFinalizersTest/place"); +export const marked = Symbol("remoteFinalizersTest/marked"); +export const flagged = Symbol("remoteFinalizersTest/flagged"); +export const widget = Symbol("remoteFinalizersTest/widget"); +export const label = Symbol("remoteFinalizersTest/label"); +export const tone = Symbol("remoteFinalizersTest/tone"); +export const title = Symbol("remoteFinalizersTest/title"); +declare module "@adobe/ecs" { + export interface Threads { + remoteFinalizersTest: true; + } + export interface Components { + [serial]: { type: number }; + [place]: { type: readonly [number, number] }; + [marked]: { type: true }; + [flagged]: { type: true }; + [widget]: { type: Widget | null; thread: "remoteFinalizersTest" }; + [label]: { type: { readonly text: string } | null; thread: "remoteFinalizersTest" }; + [title]: { type: string }; + } + export interface Resources { + [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest" }; + } +} +/** A value that isn't data, which only its thread holds. */ +type Widget = { readonly tag: string; readonly draw: () => string }; +Component.register(serial, { type: { type: "integer" } }); +Component.register(place, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(marked, { type: { const: true } }); +Component.register(flagged, { type: { const: true } }); +Component.register(widget, { thread: "remoteFinalizersTest" }); +const text = { type: "object", properties: { text: { type: "string" } } } as const; +Component.register(label, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "remoteFinalizersTest" }); +Resource.register(tone, { type: { ...text, default: { text: "quiet" } }, thread: "remoteFinalizersTest" }); +/** A string, in shared memory, which the world's thread writes and the finalizer on the thread reads. */ +Component.register(title, { type: { type: "string" } }); + +/** A stop as the finalizer saw it, on the thread it ran on. */ +export type Released = { readonly serial: number; readonly place: readonly number[]; readonly marked: boolean; readonly widget: string | null; readonly label: string | null; readonly tone: string }; + +/** Every stop finalized on this thread, in order, with the entity each was. */ +export const released: Released[] = []; +export const finalized: Entity[] = []; +/** Serials whose stops throw. */ +export const throwing = new Set(); + +/** On the finalizers' thread: what each stop held there, its structs before the sync point applied. */ +export const release = Finalizer.create({ name: "remoteFinalizersTest/release", components: { read: [serial, place, marked, widget, label] }, resources: { read: [tone] } }, (row, { resources }) => { + finalized.push(row.entity); + released.push({ serial: row[serial], place: [...row[place]], marked: row[marked], widget: row[widget]?.tag ?? null, label: row[label]?.text ?? null, tone: resources[tone]?.text }); + if (throwing.has(row[serial])) throw new Error(`remoteFinalizersTest: ${row[serial]} throws`); +}); + +/** Every titled stop finalized on this thread, in order, with the title it read. */ +export const entitled: { readonly serial: number; readonly title: string }[] = []; + +/** On the finalizers' thread: what each titled stop's title was before its sync point applied. */ +export const entitle = Finalizer.create({ name: "remoteFinalizersTest/entitle", components: { read: [serial, title, widget] } }, row => { + entitled.push({ serial: row[serial], title: row[title] }); +}); + +/** Spawns titled rows from the world's thread, with the thread's widget at null. */ +export const spawnTitled = System.create({ name: "remoteFinalizersTest/spawnTitled", input: { type: "object", properties: { from: { type: "integer" }, count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, title, widget])({ [serial]: k, [title]: `t${k}`, [widget]: null }); +}); +/** From the world's thread: every title as another of the same size, so the records of titles let go are reused. */ +export const retitle = System.create({ name: "remoteFinalizersTest/retitle", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [title] } }, (row, { input }) => { + row[title] = `r${input.round}`; +}); + +/** Spawns from the world's thread: structs, the thread's objects at null and their start. */ +export const spawn = System.create({ name: "remoteFinalizersTest/spawn", input: { type: "object", properties: { from: { type: "integer" }, count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, place, marked, widget, label])({ [serial]: k, [place]: [k, -k], [widget]: null }); +}); +/** On the thread: widgets written in place. */ +export const attach = System.create({ name: "remoteFinalizersTest/attach", components: { read: [serial], write: [widget] } }, row => { + const tag = `w${row[serial]}`; + row[widget] = { tag, draw: () => tag }; +}); +/** On the thread: labels given by command, staged there, for odd serials. */ +export const relabel = System.create({ name: "remoteFinalizersTest/relabel", components: { read: [serial, label] } }, (row, { commands }) => { + const relabelled = { text: `l${row[serial]}${row[label]?.text === "none" ? "" : "+"}` }; + if (row[serial] % 2 === 1) commands.add(row.entity, label, relabelled); +}); +/** On the thread: its object resource. */ +export const retone = System.create({ name: "remoteFinalizersTest/retone", input: { type: "object", properties: { tone: { type: "string" } } }, resources: { write: [tone] } }, ({ input, resources }) => { + resources[tone] = { text: input.tone }; +}); +/** From the world's thread: places move, so a stop's values are those before the sync point applies. */ +export const shift = System.create({ name: "remoteFinalizersTest/shift", components: { write: [place] } }, row => { + row[place] = [row[place][0] + 100, row[place][1]]; +}); +/** From the world's thread: despawns these serials. */ +export const despawn = System.create({ name: "remoteFinalizersTest/despawn", input: { type: "object", properties: { serials: { type: "array", items: { type: "integer" } } } }, components: { read: [serial] } }, (row, { input, commands }) => { + if (input.serials.includes(row[serial])) commands.despawn(row.entity); +}); +/** From the world's thread: removes widgets of these serials, a stop that despawns nothing. */ +export const strip = System.create({ name: "remoteFinalizersTest/strip", input: { type: "object", properties: { serials: { type: "array", items: { type: "integer" } } } }, components: { read: [serial], has: [widget] } }, (row, { input, commands }) => { + if (input.serials.includes(row[serial])) commands.remove(row.entity, widget); +}); +/** From the world's thread: every row moves archetype, flagged on one run and not on the next. */ +export const flag = System.create({ name: "remoteFinalizersTest/flag", components: { has: [serial], not: [flagged] } }, (row, { commands }) => { + commands.add(row.entity, flagged); +}); +export const unflag = System.create({ name: "remoteFinalizersTest/unflag", components: { has: [serial, flagged] } }, (row, { commands }) => { + commands.remove(row.entity, flagged); +}); + +/** Waits until `done` holds, letting other tasks run, for at most `milliseconds`. */ +export const eventually = async (done: () => boolean | Promise, milliseconds = 20000): Promise => { + const deadline = performance.now() + milliseconds; + while (!await done() && performance.now() < deadline) await new Promise(resolve => setTimeout(resolve, 1)); + return done(); +}; + +/** Stops in a comparable order: by serial, then as they came. */ +export const sorted = (stops: readonly Released[]): readonly Released[] => stops.map((stop, k) => [stop, k] as const).sort((a, b) => a[0].serial - b[0].serial || a[1] - b[1]).map(([stop]) => stop); + +/** + * The runs both worlds take: spawns, widgets and labels given on the finalizers' thread, despawns and a removal in + * sync points that move other rows into the stopped ones, a crowd moved past what a joined thread's log holds before + * its despawns, and its object resource changed. `stops` is how many stops the finalizer has seen, wherever it runs; + * each step waits until it saw `expected` of them, with what an inline world saw first. + */ +export const scenario = async (world: World, stops: () => number | Promise, expected: readonly number[] = []): Promise => { + const seen: number[] = []; + const step = async (run: Promise): Promise => { + await run; + const wanted = expected[seen.length]; + if (wanted !== undefined) await eventually(async () => await stops() >= wanted); + seen.push(await stops()); + }; + await step(world.run(spawn, { from: 0, count: 12 })); + await step(world.run(attach)); + await step(world.run(relabel)); + await step(world.run(shift)); + await step(world.run(despawn, { serials: [1, 2, 5, 11] })); + await step(world.run(strip, { serials: [3] })); + await step(world.run(retone, { tone: "loud" })); + await step(world.run(despawn, { serials: [3, 4] })); + await step(world.run(spawn, { from: 100, count: 300 })); + await step(world.run(attach)); + await step(world.run(relabel)); + for (let k = 0; k < 30; k++) await step(world.run(k % 2 === 0 ? flag : unflag)); + await step(world.run(despawn, { serials: [100, 101, 150, 299] })); + await step(world.run(despawn, { serials: [6, 7] })); + return seen; +}; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts new file mode 100644 index 000000000..0364ac5c1 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Observe } from "../../observe/index.js"; +import { identify } from "../../interface/registry/ids.test-helper.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { indexOf } from "./entities.js"; +import { attach, despawn, eventually, finalized, marked, place, released, retone, scenario, serial, sorted, spawn, throwing, tone, widget } from "./remote-finalizers.test-helper.js"; +import { runReported } from "./reports.test-helper.js"; + +// Finalizers on a joined thread, with the world and the thread on this one, joined through a MessageChannel. + +/** The first value an observer gives that `accept` takes. */ +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +/** Once the thread shared last has status `status`. */ +const statusOf = (world: CreatedWorld, status: "joined" | "gone") => + until(world.threads, threads => threads.at(-1)?.status === status); + +/** A world shared with a thread on this one, which claims the finalizers' thread. */ +const joined = async (): Promise<{ readonly world: CreatedWorld; readonly remote: World }> => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["remoteFinalizersTest"] }); + await statusOf(world, "joined"); + return { world, remote }; +}; + +const reset = (): void => { + released.length = 0; + finalized.length = 0; + throwing.clear(); +}; + +describe("finalizers on a joined thread", () => { + it("leave a joined thread's objects out of a changes finalizer watching everything here, as whole-entity observers do", async () => { + reset(); + const seen: (readonly symbol[] | null)[][] = []; + let watching = false; + Finalizer.changes({ name: "remoteFinalizersTest/everything" }, changes => { + if (watching) for (const group of changes.groups) seen.push([group.to, group.added]); + }); + const { world, remote } = await joined(); + watching = true; + await world.run(spawn, { from: 0, count: 1 }); + watching = false; + expect(seen).toEqual([[[serial, place, marked], [serial, place, marked]]]); + await remote.dispose(); + await world.dispose(); + }); + it("run once per stop there, as an inline world's do: structs before the sync point applies, the thread's objects at their starts, null, written or staged, after its log lapped too", async () => { + reset(); + const inline = createWorld({ [workers]: 0 }); + const expected = await scenario(inline, () => released.length); + await inline.dispose(); + const inlineStops = sorted(released); + reset(); + const { world, remote } = await joined(); + const seen = await scenario(world, () => released.length, expected); + expect(seen).toEqual(expected); + await world.dispose(); + expect(await eventually(() => released.length === inlineStops.length)).toBe(true); + expect(sorted(released)).toEqual(inlineStops); + expect(released.find(stop => stop.serial === 1)).toEqual({ serial: 1, place: [101, -1], marked: true, widget: "w1", label: "l1", tone: "quiet" }); + expect(released.find(stop => stop.serial === 2)).toEqual({ serial: 2, place: [102, -2], marked: true, widget: "w2", label: "none", tone: "quiet" }); + // Removing a widget stops it there, which a despawn later doesn't again. + expect(released.filter(stop => stop.serial === 3)).toEqual([{ serial: 3, place: [103, -3], marked: true, widget: "w3", label: "l3", tone: "quiet" }]); + expect(released.find(stop => stop.serial === 299)).toEqual({ serial: 299, place: [299, -299], marked: true, widget: "w299", label: "l299", tone: "loud" }); + await remote.dispose(); + }); + it("run before the index of an entity they stopped is reused", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 20 }); + const stopped = new Map(); + let reusedEarly = 0; + let reused = 0; + // Spawns check each entity they reserve: an index whose last entity stopped is reused only once it was finalized. + const check = System.create(identify({ name: "remoteFinalizersTest/check", input: { type: "object", properties: { serial: { type: "integer" } } } }, "remoteFinalizersTest#check"), ({ input, commands }) => { + const entity = commands.spawn([serial, place, marked, widget])({ [serial]: input.serial, [place]: [0, 0], [widget]: null }); + const before = stopped.get(indexOf(entity)); + if (before === undefined) return; + reused++; + if (!finalized.includes(before)) reusedEarly++; + }); + const entities = await Observe.toPromise(Observe.select(world, { required: [serial] })); + for (const entity of entities) stopped.set(indexOf(entity), entity); + const runs: Promise[] = []; + for (let k = 0; k < 20; k++) { + // Not awaited: the next run starts before the thread had a turn to finalize. + runs.push(world.run(despawn, { serials: [k] }), world.run(check, { serial: 1000 + k })); + } + await Promise.all(runs); + expect(await eventually(() => finalized.length === 20)).toBe(true); + for (let k = 0; k < 20; k++) await world.run(check, { serial: 2000 + k }); + expect(reusedEarly).toBe(0); + expect(reused).toBeGreaterThan(0); + await remote.dispose(); + await world.dispose(); + }); + it("that throw are in a later run's report", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 3 }); + throwing.add(1); + await world.run(despawn, { serials: [0, 1, 2] }); + expect(await eventually(() => released.length === 3)).toBe(true); + // Its failure reaches the world by message, in time for a later run. + let report = await runReported(world, despawn, { serials: [] }); + for (let k = 0; k < 100 && report.failures.length === 0; k++) { + await new Promise(resolve => setTimeout(resolve, 1)); + report = await runReported(world, despawn, { serials: [] }); + } + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "remoteFinalizersTest/release", "remoteFinalizersTest: 1 throws"]]); + expect((await runReported(world, despawn, { serials: [] })).failures).toEqual([]); + await remote.dispose(); + await world.dispose(); + }); + it("run there for every entity they match once the world is disposed, after the stops published", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 5 }); + await world.run(attach); + await world.run(despawn, { serials: [4] }); + await world.dispose(); + expect(await eventually(() => released.length === 5)).toBe(true); + expect(released.map(stop => [stop.serial, stop.widget])).toEqual([[4, "w4"], [0, "w0"], [1, "w1"], [2, "w2"], [3, "w3"]]); + await remote.dispose(); + }); + it("finalize what was published when their thread leaves, and run on the world's thread once it's gone", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 3 }); + await world.run(attach); + await world.run(despawn, { serials: [0] }); + await remote.dispose(); + await statusOf(world, "gone"); + expect(released.map(stop => [stop.serial, stop.widget])).toEqual([[0, "w0"]]); + // Its objects came back as their starts, with the thread's values its own. + const report = await runReported(world, despawn, { serials: [1] }); + expect(report.failures).toEqual([]); + expect(released.map(stop => [stop.serial, stop.widget])).toEqual([[0, "w0"], [1, null]]); + await world.dispose(); + expect(released.map(stop => stop.serial)).toEqual([0, 1, 2]); + }); + // Last: the finalizers it creates stay in the program. + it("created after the claim run there; one the thread can't name doesn't activate, reported once", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 2 }); + const late: string[] = []; + Finalizer.create(identify({ name: "remoteFinalizersTest/late", components: { read: [widget, serial] } }, "remoteFinalizersTest#late"), row => { late.push(`${row[serial]}:${String(row[widget])}`); }); + // Declared in a function, so the build gives it no id. + Finalizer.create({ name: "remoteFinalizersTest/unnamed", components: { read: [widget] } }, () => {}); + const first = await runReported(world, despawn, { serials: [0] }); + expect(first.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "remoteFinalizersTest/unnamed", 'Finalizer "remoteFinalizersTest/unnamed" runs on thread "remoteFinalizersTest", which a joined thread fills, and has no id, so that thread can\'t name it; the build gives top-level finalizers one.']]); + expect(await eventually(() => late.length === 1)).toBe(true); + expect((await runReported(world, despawn, { serials: [1] })).failures).toEqual([]); + expect(await eventually(() => late.length === 2)).toBe(true); + expect(late).toEqual(["0:null", "1:null"]); + // Placed there by the thread's object resource alone: stops of rows holding none of its objects, which its log + // has no entries for, run there too. + const heard: string[] = []; + Finalizer.create(identify({ name: "remoteFinalizersTest/heard", components: { read: [serial], not: [widget] }, resources: { read: [tone] } }, "remoteFinalizersTest#heard"), (row, { resources }) => { heard.push(`${row[serial]}:${resources[tone].text}`); }); + const plain = System.create({ name: "remoteFinalizersTest/plain" }, ({ commands }) => { + for (let k = 0; k < 3; k++) commands.spawn([serial, place])({ [serial]: 50 + k, [place]: [0, 0] }); + }); + await world.run(plain); + await world.run(retone, { tone: "soft" }); + expect((await runReported(world, despawn, { serials: [50, 52] })).failures).toEqual([]); + expect(await eventually(() => heard.length === 2)).toBe(true); + expect(heard).toEqual(["50:soft", "52:soft"]); + await remote.dispose(); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/remote-objects-worker.fixture.ts b/packages/ecs/src/implementation/world/remote-objects-worker.fixture.ts new file mode 100644 index 000000000..acb071221 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-objects-worker.fixture.ts @@ -0,0 +1,20 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { asEntity } from "./entities.js"; +import { join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { watch, type Observers, type Watched } from "./remote-objects.test-helper.js"; + +/** A thread joining a world through the port it's sent, claiming "remoteObjects", and watching an entity there. */ + +type Port = MessagePort | NodeMessagePort; +export type ToRemoteObjectsWorker = { readonly kind: "watch"; readonly port: Port; readonly entity: number; readonly count: number; readonly observers: Observers }; +export type FromRemoteObjectsWorker = { readonly kind: "watched"; readonly watched: Watched }; + +const channel = scopeChannel(); +channel.onMessage(async message => { + if ("protocol" in message) return; + const remote = join(message.port, { threads: ["remoteObjects"] }); + const watched = await watch(remote, asEntity(message.entity), message.count, message.observers); + await remote.dispose(); + channel.post({ kind: "watched", watched }); +}); diff --git a/packages/ecs/src/implementation/world/remote-objects.cases.ts b/packages/ecs/src/implementation/world/remote-objects.cases.ts new file mode 100644 index 000000000..d223665ab --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-objects.cases.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./remote-objects.fixture.js"; + +/** Objects a joined worker fills: the cases, over the fixture built for node or from source. */ +export const remoteObjectsCases = (load: () => Promise) => describe("objects a joined thread fills", () => { + it("give the observer factories on a joined worker its objects as of the struct values they're read with, through the structure epoch while the world applies", async () => { + const fixture = await load(); + const watched = await fixture.watchedWhileApplying(400, "factory"); + expect(watched).toEqual({ values: 400, inconsistent: 0, captions: ["none", null] }); + }, 30000); + it("give a joined worker's observer systems its objects as of the struct values they look up with", async () => { + const fixture = await load(); + const watched = await fixture.watchedWhileApplying(400, "observer"); + expect(watched).toEqual({ values: 400, inconsistent: 0, captions: ["none", null] }); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/remote-objects.fixture.ts b/packages/ecs/src/implementation/world/remote-objects.fixture.ts new file mode 100644 index 000000000..38e2dccd2 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-objects.fixture.ts @@ -0,0 +1,59 @@ +/// +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld } from "./create.js"; +import { backToBack } from "./loop.js"; +import type { FromRemoteObjectsWorker, ToRemoteObjectsWorker } from "./remote-objects-worker.fixture.js"; +import { churn, seed, serial, type Observers, type Watched } from "./remote-objects.test-helper.js"; + +/** + * Scenarios of objects a joined worker fills, run built in node and from source in chromium: the worker watches an + * entity whose objects change and move every tick, while the world here applies sync points back to back. + */ + +type Worker = { readonly ask: (message: ToRemoteObjectsWorker, transfer: readonly unknown[]) => Promise; readonly terminate: () => Promise }; + +const workerFile = "./remote-objects-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./remote-objects-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => resolve(event.data); + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer) => new Promise(resolve => { + worker.once("message", resolve); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + }; +}; + +/** + * A worker watching an entity's objects here, through `observers`, while the world ticks back to back, moving and + * changing them each tick. + */ +export const watchedWhileApplying = async (count: number, observers: Observers): Promise => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + await world.run(seed); + const [entity] = await Observe.toPromise(Observe.select(world, { required: [serial], where: { [serial]: 0 } })); + const worker = start(); + const { port1, port2 } = new MessageChannel(); + world.share(port1); + world.add(churn); + const reply = await worker.ask({ kind: "watch", port: port2, entity, count, observers }, [port2]); + await worker.terminate(); + await world.dispose(); + driver.close(); + return reply.kind === "watched" ? reply.watched : null; +}; diff --git a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts new file mode 100644 index 000000000..f31748dd8 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -0,0 +1,107 @@ +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { Observe } from "../../observe/index.js"; + +// Keys and systems both threads of the remote objects fixtures import: objects of thread "remoteObjects", which a +// joined worker fills, and what it sees of them while the world applies sync points back to back. + +export const serial = Symbol("remoteObjectsTest/serial"); +export const phase = Symbol("remoteObjectsTest/phase"); +export const flag = Symbol("remoteObjectsTest/flag"); +export const caption = Symbol("remoteObjectsTest/caption"); +declare module "@adobe/ecs" { + export interface Threads { + remoteObjects: true; + } + export interface Components { + [serial]: { type: number }; + [phase]: { type: number }; + [flag]: { type: true }; + [caption]: { type: { readonly text: string } | null; thread: "remoteObjects" }; + } +} +Component.register(serial, { type: { type: "number" } }); +Component.register(phase, { type: { type: "number" } }); +Component.register(flag, { type: { const: true } }); +/** The caption's start, which a command here gives it by this value. */ +const none = { text: "none" }; +Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "remoteObjects" }); + +/** A caption's text, null for none, as the watchers record it. */ +const textOf = (value: { readonly text: string } | null): string | null => value === null ? null : value.text; + +/** The watched entity, serial 0. */ +export const seed = System.create({ name: "remoteObjectsTest/seed" }, ({ commands }) => { + commands.spawn([serial, phase, caption])({ [serial]: 0, [phase]: 0 }); +}); + +/** + * Every tick: the watched entity's phase moves on, and its caption and flag follow it at the sync point, null and + * flagged when it's odd, so it moves archetype every tick; the other entities despawn, and four more spawn into its + * archetypes, so rows swap into the holes they leave. + */ +export const churn = System.create({ name: "remoteObjectsTest/churn", components: { read: [serial], write: [phase] } }, (row, { commands }) => { + if (row[serial] !== 0) { + commands.despawn(row.entity); + return; + } + const next = row[phase] + 1; + row[phase] = next; + if (next % 2 === 1) { + commands.add(row.entity, flag); + commands.add(row.entity, caption, null); + } + else { + commands.remove(row.entity, flag); + commands.add(row.entity, caption, none); + } + for (let k = 1; k <= 4; k++) { + if (k % 2 === 0) commands.spawn([serial, phase, caption])({ [serial]: k, [phase]: 0, [caption]: null }); + else commands.spawn([serial, phase, caption, flag])({ [serial]: k, [phase]: 0 }); + } +}); + +/** What a joined thread saw of the watched entity's objects: values, those whose objects disagreed with its structs, and captions seen. */ +export type Watched = { readonly values: number; readonly inconsistent: number; readonly captions: readonly (string | null)[] }; + +/** What watches: the observer factories on the joined thread's world, or an observer system it added. */ +export type Observers = "factory" | "observer"; + +/** The entity `observerWatch` watches, and what it's told of each value. */ +const observer: { entity: Entity | null; seen: ((odd: boolean, held: boolean, text: string | null | undefined) => void) | null } = { entity: null, seen: null }; + +/** After each sync point, on the joined thread: the watched entity's phase and caption, and whether it's flagged. */ +export const observerWatch = System.create({ name: "remoteObjectsTest/observerWatch", observer: true, lookup: { values: { read: [phase, caption] }, flagged: { has: [flag] } } }, ({ lookup }) => { + if (observer.entity === null) return; + const row = lookup.values(observer.entity); + if (row === null) return; + observer.seen?.(row[phase] % 2 === 1, lookup.flagged(observer.entity) !== null, textOf(row[caption])); +}); + +/** Watches `entity` until `count` values came, checking each value's objects agree with its structs. */ +export const watch = (remote: World, entity: Entity, count: number, observers: Observers): Promise => new Promise(resolve => { + let values = 0; + let inconsistent = 0; + const captions = new Set(); + const stops: (() => void)[] = []; + const seen = (odd: boolean, held: boolean, text: string | null | undefined): void => { + values++; + if (text !== undefined) captions.add(text); + if (held !== odd || text !== (odd ? null : "none")) inconsistent++; + if (values !== count) return; + queueMicrotask(() => { for (const stop of stops) stop(); }); + resolve({ values, inconsistent, captions: [...captions].sort() }); + }; + if (observers === "observer") { + observer.entity = entity; + observer.seen = (odd, held, text) => { if (values < count) seen(odd, held, text); }; + remote.add(observerWatch); + stops.push(() => remote.remove(observerWatch)); + return; + } + stops.push(Observe.entity(remote, entity, { required: [phase, caption], optional: [flag] })(value => { + if (value !== null && values < count) seen(value[phase] % 2 === 1, value[flag] === true, textOf(value[caption])); + })); +}); diff --git a/packages/ecs/src/implementation/world/remote-systems.ts b/packages/ecs/src/implementation/world/remote-systems.ts new file mode 100644 index 000000000..73871c1ea --- /dev/null +++ b/packages/ecs/src/implementation/world/remote-systems.ts @@ -0,0 +1,627 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import { entityWatchesOf, lookupComponentsOf } from './lookup-keys.js'; +import type { Leaf as FinalizerLeaf } from '../../interface/finalizer/created.js'; +import type { Warning } from '../../interface/report/failure.js'; +import { declarationOf, idOfItem, idOfKey, indexOfLookup, labelOf, labelOfItem, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import { unsharedKeyOf } from './index-keys.js'; +import { layoutOf } from '../../interface/registry/layout.js'; +import type { Archetype } from './archetype.js'; +import { chunkShift } from './kernel.js'; +import type { LocalIds, Segment } from './command-ring.js'; +import type { ToJoined } from './joining.js'; +import { lineWords, type WorkerError } from './protocol.js'; +import { reductionTargets, type ReductionTarget } from './reductions.js'; +import { holdingNothing, resourceWritesOf, type Runner } from './runner.js'; +import { acquireLookups, type IndexLookups, type WorldIndex } from './indexes.js'; +import { writeIndexParameters } from './kernel-indexes.js'; +import { recordWordsOf } from './thread-indexes.js'; +import { idOf, type Key, type State } from './state.js'; +import { logBoundedWord, logBoundWord, logSyncWord, type MoveLog } from './move-log.js'; +import { createStopWriter, freeStops, type StopWriter } from './stop-lists.js'; +import { freeTail, stampWritten, type Tail } from './tails.js'; +import { isHeld } from './transfers.js'; +import { + completionCapacityWord, completionMessage, completionOffsetWord, completionSystems, completionWords, executedFlag, failedFlag, + payloadCapacityWord, payloadCount, payloadInput, payloadOffsetWord, payloadSequence, payloadSync, payloadSystems, payloadWords, ranFlag, + resultEnd, resultFields, resultFlags, resultStart, ringBaseWord, ringCapacityWord, ringCursorWord, ringNeededWord, spilledFlag, systemFields, + systemFolds, systemIndex, systemLastRun, systemTail, systemTick, systemTier, taskBlockWords, taskDoneWord, taskFailedWord, taskPostedWord, +} from './thread-tasks.js'; + +// The world's side of systems on joined threads (threads.md, "Systems on other threads"): a runner standing for each +// system placed on a thread a joined thread fills, and that thread's task block and command ring, through which a +// tier, or consecutive tiers all on that thread, run as one task. + +/** What a task of a joined thread's gave back for one of its systems. */ +export type RemoteResult = { + flags: number; + segment: Segment | undefined; + error: unknown; +}; + +/** A system placed on a joined thread: its channel, its index there, and what its last task gave back. */ +export type RemoteSystem = { + readonly channel: TaskChannel; + readonly index: number; + readonly label: string; + /** Its warning, listed in each run's report its tier blocks. */ + readonly warning: Warning; + readonly targets: readonly ReductionTarget[]; + /** Its folds' leaves, as the last task gave them, by target. */ + readonly folds: readonly Float64Array[]; + /** Where its folds start among a task's. */ + readonly foldCount: number; + /** Components it filters on with `changed`, whose GPU column stamps reach the thread as row ticks. */ + readonly changed: readonly number[]; + /** Components its lookups by entity watch, whose GPU column stamps reach the thread as row ticks in every archetype. */ + readonly watched: readonly number[]; + /** The world's archetypes, which an entity it looks up may be in. */ + readonly archetypes: readonly Archetype[]; + /** Components it writes, as the tick of each task it's in marks them written. */ + readonly writes: readonly number[]; + readonly declaresInput: boolean; + lastRun: number; + readonly result: RemoteResult; + /** Its block and snapshot once it ran as a tail (`tails.ts`). */ + tail: Tail | null; + /** Its lookups through indexes: each index, the components it watches there, and the record each task's parameters are written to. */ + readonly indexes: readonly RemoteIndex[]; +}; + +/** A lookup through an index by a system on a joined thread: the thread reads the index's arrays where its record says. */ +type RemoteIndex = { readonly index: WorldIndex; readonly watched: readonly number[]; readonly record: number }; + +/** The world's end of a joined thread's task block and command ring. */ +export type TaskChannel = { + /** The joined thread's number, its log's. */ + readonly log: MoveLog; + /** The byte offset of its task block, which the thread is posted. */ + readonly block: number; + /** The thread's own numbers for keys, as the world's ids, for its segments. */ + readonly local: LocalIds; + /** Whether it's gone, so its systems are skipped with a `"thread"` failure. */ + readonly gone: () => boolean; + /** Why its systems are skipped once it's gone. */ + readonly goneError: () => Error; + /** + * Runs `systems`, with the tier of each within the task, as one task: each takes the next tick, and its result is + * in its `result` once this resolves. Resolves with false when the thread is gone, having given back nothing. + */ + readonly run: (state: State, systems: readonly RemoteSystem[], tiers: readonly number[], args: unknown, tails?: boolean) => Promise; + /** Resolves once no task is in flight, as a tail may be: true once done, false once the thread is gone. */ + readonly idle: () => Promise; + /** Whether a task is in flight. */ + readonly busy: () => boolean; + /** Tails of its systems, whose blocks are freed with it. */ + readonly owned: Tail[]; + /** A task's message, when its completion says one follows. */ + readonly completed: (message: Extract) => void; + /** Places a system on the thread, posting its setup; returns its index there. */ + readonly place: (state: State, leaf: Leaf, indexes: readonly (readonly [string, number])[]) => number; + /** + * Stops running the system at an index there: once this returns the thread runs it in no task, a task already + * posted included, through a word in the memory the thread checks before each system it runs. The index is placed + * again only once the thread says it dropped what it held for it, after every task posted before. + */ + readonly unplace: (index: number) => void; + /** The thread dropped what it held for systems unplaced at these indexes. */ + readonly unplaced: (indexes: readonly number[]) => void; + /** For tests of what a world holds: how many indexes there hold a system, or are held until the thread drops it. */ + readonly placed: () => number; + /** Places a finalizer on the thread, posting its setup; returns its index there. Throws for one the thread can't name. */ + readonly placeFinalizer: (state: State, finalizer: FinalizerLeaf) => number; + /** The finalizer placed at an index there, for its failures there. */ + readonly finalizerAt: (index: number) => FinalizerLeaf | undefined; + /** Its finalizers' stop lists. */ + readonly stops: StopWriter; + /** Whether a command giving one of its staged `transfer` values dropped this sync point, so it's woken to release it. */ + dropped: boolean; + /** Wakes the thread, so it runs its finalizers for the stops just published. */ + readonly wake: () => void; + /** At a sync point, once segments are drained and no task runs: empties the ring, growing it to what tasks needed. */ + readonly rewind: () => void; + /** Once the thread is gone: settles a task in flight, which gives back nothing. */ + readonly end: () => void; + /** Frees its blocks once the thread left, as a thread that died may still be writing them. */ + readonly free: () => void; +}; + +/** How long the world spins for a task before it waits: about one wake-up's latency. */ +const spinMilliseconds = 0.02; + +/** A command ring's first size in bytes; a sync point grows it to what a task's systems needed. */ +const initialRingBytes = 1 << 16; + +type Block = { offset: number; bytes: number }; + +const errorOf = ({ name, message, stack }: WorkerError): Error => Object.assign(new Error(message), { name, stack }); + +/** + * A joined thread's task block, in the memory, with its command ring: `post` reaches the thread and `wake` wakes it; + * where `Atomics.waitAsync` can't wait here, the thread's `completed` message says a task is done. `label` names the + * threads it claims; `local` maps its own numbers for keys. + */ +export const createTaskChannel = (state: State, log: MoveLog, post: (message: ToJoined) => void, wake: () => void, label: string, local: LocalIds): TaskChannel => { + const { memory: arena } = state; + const lineBytes = lineWords * 4; + const blockBytes = taskBlockWords * 4 + lineBytes; + const raw = arena.allocate(blockBytes); + const start = Math.ceil(raw / lineBytes) * lineBytes; + const control = new Int32Array(arena.memory.buffer, start, taskBlockWords).fill(0); + let ring: Block = { offset: arena.allocate(initialRingBytes), bytes: initialRingBytes }; + let payload: Block = { offset: 0, bytes: 0 }; + let completion: Block = { offset: 0, bytes: 0 }; + let payloadView = new Float64Array(0); + let completionView = new Float64Array(0); + const placeRing = (): void => { + Atomics.store(control, ringBaseWord, ring.offset); + Atomics.store(control, ringCapacityWord, ring.bytes); + Atomics.store(control, ringCursorWord, 0); + Atomics.store(control, ringNeededWord, 0); + }; + placeRing(); + let sequence = 0; + let ended = false; + /** Settles the task in flight: true once done, false once the thread is gone. */ + let settle: ((done: boolean) => void) | null = null; + let message: Extract | null = null; + let wrote = false; + let placed = 0; + const goneError = (): Error => new Error(`Thread "${label}" is gone, so its systems don't run.`); + + /** Grows a block of f64 words, while no task runs. */ + const ensure = (block: Block, words: number): Block => { + if (block.bytes >= words * 8) return block; + if (block.bytes > 0) arena.free(block.offset, block.bytes); + const bytes = Math.max(words * 8, 2 * block.bytes, 256); + return { offset: arena.allocate(bytes), bytes }; + }; + + const views = (): void => { + const { buffer } = arena.memory; + if (payloadView.buffer !== buffer || payloadView.byteOffset !== payload.offset || payloadView.byteLength !== payload.bytes) payloadView = new Float64Array(buffer, payload.offset, payload.bytes / 8); + if (completionView.buffer !== buffer || completionView.byteOffset !== completion.offset || completionView.byteLength !== completion.bytes) completionView = new Float64Array(buffer, completion.offset, completion.bytes / 8); + }; + + /** Reads back each system's flags, segment and folds once the task is done and its message, if any, arrived. */ + const collect = (systems: readonly RemoteSystem[]): void => { + views(); + const folds = completionSystems + systems.length * resultFields; + const { buffer } = arena.memory; + for (let k = 0; k < systems.length; k++) { + const system = systems[k]; + const at = completionSystems + k * resultFields; + const flags = completionView[at + resultFlags]; + const { result } = system; + result.flags = flags; + result.error = undefined; + result.segment = undefined; + const startByte = completionView[at + resultStart]; + const endByte = completionView[at + resultEnd]; + if ((flags & spilledFlag) !== 0) { + const spill = message?.spills.find(([position]) => position === k)?.[1]; + if (spill !== undefined) result.segment = { bytes: spill, tag: { tick: payloadView[payloadSystems + k * systemFields + systemTick], archetype: 0, start: k } }; + wrote = true; + } + else if (endByte > startByte) { + result.segment = { bytes: new Uint8Array(buffer, ring.offset + startByte, endByte - startByte), tag: { tick: payloadView[payloadSystems + k * systemFields + systemTick], archetype: 0, start: k } }; + wrote = true; + } + if ((flags & failedFlag) !== 0) { + const failure = message?.errors.find(([position]) => position === k)?.[1]; + result.error = failure === undefined ? new Error(`System "${system.label}" failed on thread "${label}".`) : errorOf(failure); + } + if ((flags & executedFlag) !== 0) system.lastRun = payloadView[payloadSystems + k * systemFields + systemTick]; + const stamps = message?.stamps; + if (stamps !== undefined && stamps.length > 0) stampWritten(state, stamps, k, payloadView[payloadSystems + k * systemFields + systemTick]); + if ((flags & ranFlag) !== 0 && (flags & failedFlag) === 0) { + let offset = folds + payloadView[payloadSystems + k * systemFields + systemFolds]; + for (const leaves of system.folds) for (let i = 0; i < leaves.length; i++) leaves[i] = completionView[offset++]; + } + } + }; + + /** Fails every system of a task the thread failed whole, running none of it, with the error its message gives. */ + const failWhole = (systems: readonly RemoteSystem[]): void => { + const failure = message?.errors[0]?.[1]; + for (const system of systems) { + const { result } = system; + result.flags = failedFlag; + result.segment = undefined; + result.error = failure === undefined ? new Error(`System "${system.label}" failed on thread "${label}".`) : errorOf(failure); + } + }; + + /** Settles a wait for the task in flight once the thread stored it done. */ + const doneNow = (): void => { + const resolve = settle; + settle = null; + resolve?.(true); + }; + /** Waits without blocking for the done word to move past `current`, or for the thread's message, or its end. */ + const changed = (current: number): Promise => new Promise(resolve => { + settle = resolve; + if (typeof Atomics.waitAsync !== "function") return; + const waited = Atomics.waitAsync(control, taskDoneWord, current); + if (waited.async) void waited.value.then(doneNow); + else doneNow(); + }); + + /** + * Resolves once the thread stored task `expected` done, and its message arrived when one follows, or once it's + * gone. Spins briefly first: a short task finishes before a wait's wake-up would arrive. + */ + const whenDone = async (expected: number): Promise => { + const deadline = performance.now() + spinMilliseconds; + while (Atomics.load(control, taskDoneWord) < expected && performance.now() < deadline); + for (let current = Atomics.load(control, taskDoneWord); current < expected; current = Atomics.load(control, taskDoneWord)) { + if (ended || !await changed(current)) return false; + } + const follows = completionView[completionMessage] !== 0 || Atomics.load(control, taskFailedWord) === expected; + if (follows && (message === null || message.sequence !== expected)) { + const arrived = new Promise(resolve => { settle = resolve; }); + if (!await arrived) return false; + } + return true; + }; + + /** Parameters of an index lookup, reused, and a view of the memory they're copied into. */ + const parameters: number[] = []; + let words = new Float64Array(0); + /** Writes where each index a system looks up through keeps its arrays, as of now, into its records for the thread. */ + const writeRecords = (system: RemoteSystem): void => { + if (words.buffer !== arena.memory.buffer) words = new Float64Array(arena.memory.buffer); + for (const { index, watched, record } of system.indexes) { + const count = writeIndexParameters({ index, watched }, system.lastRun, parameters, 0); + for (let k = 0; k < count; k++) words[record / 8 + k] = parameters[k]; + } + }; + + /** The systems of the task in flight, kept as posted: the caller reuses what it passed. */ + const flight: RemoteSystem[] = []; + let flying: Promise | null = null; + const settledTask = Promise.resolve(true); + const notRun = Promise.resolve(false); + const run = (state: State, systems: readonly RemoteSystem[], tiers: readonly number[], args: unknown, tails = false): Promise => { + if (ended) return notRun; + const task = runTask(state, systems, tiers, args, tails); + flying = task; + return task; + }; + const runTask = async (state: State, systems: readonly RemoteSystem[], tiers: readonly number[], args: unknown, tails: boolean): Promise => { + if (ended) return false; + flight.length = 0; + for (let k = 0; k < systems.length; k++) flight.push(systems[k]); + let leaves = 0; + for (const system of systems) leaves += system.foldCount; + payload = ensure(payload, payloadWords(systems.length)); + completion = ensure(completion, completionWords(systems.length, leaves)); + Atomics.store(control, payloadOffsetWord, payload.offset); + Atomics.store(control, payloadCapacityWord, payload.bytes); + Atomics.store(control, completionOffsetWord, completion.offset); + Atomics.store(control, completionCapacityWord, completion.bytes); + views(); + const next = ++sequence; + message = null; + payloadView[payloadSequence] = next; + payloadView[payloadSync] = Atomics.load(log.header, logSyncWord) >>> 0; + payloadView[payloadCount] = systems.length; + const input = args !== undefined && systems.some(system => system.declaresInput); + payloadView[payloadInput] = input ? 1 : 0; + let folds = 0; + for (let k = 0; k < systems.length; k++) { + const system = systems[k]; + const base = payloadSystems + k * systemFields; + payloadView[base + systemIndex] = system.index; + const tick = ++state.tick; + payloadView[base + systemTick] = tick; + for (let w = 0; w < system.writes.length; w++) state.written[system.writes[w]] = tick; + payloadView[base + systemLastRun] = system.lastRun; + payloadView[base + systemTier] = tiers[k]; + payloadView[base + systemFolds] = folds; + payloadView[base + systemTail] = tails ? system.tail?.block ?? 0 : 0; + folds += system.foldCount; + if (system.indexes.length !== 0) writeRecords(system); + } + // An input that moved to the thread is there already: the message names it, and the task takes it. + if (input && isHeld(args)) { + args.taken = true; + post({ protocol: "@adobe/ecs/join@1", kind: "task input", sequence: next, input: undefined, held: [args.from, args.sequence] }); + } + else if (input) post({ protocol: "@adobe/ecs/join@1", kind: "task input", sequence: next, input: args }); + completionView[completionMessage] = 0; + if (tails) { + Atomics.store(log.header, logBoundWord, payloadView[payloadSync]); + Atomics.store(log.header, logBoundedWord, 1); + } + Atomics.store(control, taskPostedWord, next); + wake(); + const done = await whenDone(next); + if (tails) Atomics.store(log.header, logBoundedWord, 0); + if (done && Atomics.load(control, taskFailedWord) === next) failWhole(flight); + else if (done) collect(flight); + flying = null; + return done; + }; + + /** An item's setup there: its id, and the world's numbers for the keys it names, by their ids. Throws without them. */ + const setupOf = (state: State, item: Leaf | FinalizerLeaf, what: string, named: readonly symbol[]): { readonly id: string; readonly keys: readonly (readonly [number, string])[] } => { + const id = idOfItem(item); + if (id === undefined) throw new Error(`${what} "${labelOfItem(item)}" runs on thread "${label}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level ${what === "System" ? "systems" : "finalizers"} one.`); + const keys: (readonly [number, string])[] = []; + for (const key of named) { + const keyId = idOfKey(key); + if (keyId === undefined) throw new Error(`${what} "${labelOfItem(item)}" names "${labelOf(key)}", which has no id, so thread "${label}" can't name it; the build gives top-level keys one.`); + keys.push([idOf(state, key), keyId]); + } + return { id, keys }; + }; + + /** By index there: the offset of its removed word, a line of its own, kept for the index's next system. */ + const removedWords: number[] = []; + /** Indexes the thread dropped, placed again before new ones; and those it hasn't dropped yet. */ + const freeIndexes: number[] = []; + const unplacing = new Set(); + const removedWord = (index: number): Int32Array => new Int32Array(arena.memory.buffer, removedWords[index], 1); + + const place = (state: State, leaf: Leaf, indexes: readonly (readonly [string, number])[]): number => { + const { id, keys } = setupOf(state, leaf, "System", keysOf(leaf)); + const index = freeIndexes.pop() ?? placed++; + const removed = removedWords[index] ??= arena.allocate(lineBytes); + Atomics.store(removedWord(index), 0, 0); + post({ protocol: "@adobe/ecs/join@1", kind: "systems", systems: [{ index, id, keys, removed, indexes }] }); + return index; + }; + + const unplace = (index: number): void => { + if (ended || removedWords[index] === undefined || unplacing.has(index)) return; + Atomics.store(removedWord(index), 0, 1); + unplacing.add(index); + // The thread drops it once it finished every task posted so far, which may run it up to its word. + post({ protocol: "@adobe/ecs/join@1", kind: "unplace", indexes: [index], sequence }); + }; + + /** Tails of systems unplaced, whose blocks go once the world settled their last task. */ + const retired: Tail[] = []; + const freeRetired = (): void => { + for (let k = retired.length - 1; k >= 0; k--) { + if (retired[k].unresolved) continue; + freeTail(state, retired[k]); + retired.splice(k, 1); + } + }; + + const unplaced = (indexes: readonly number[]): void => { + for (const index of indexes) { + if (!unplacing.delete(index)) continue; + // A tail's block goes with its system, which no task uses again. + for (let k = owned.length - 1; k >= 0; k--) { + if (owned[k].remote.index !== index) continue; + retired.push(owned[k]); + owned.splice(k, 1); + } + freeIndexes.push(index); + } + freeRetired(); + }; + + const finalizers: FinalizerLeaf[] = []; + const placeFinalizer = (state: State, finalizer: FinalizerLeaf): number => { + const { id, keys } = setupOf(state, finalizer, "Finalizer", finalizerKeysOf(finalizer)); + const index = finalizers.push(finalizer) - 1; + post({ protocol: "@adobe/ecs/join@1", kind: "finalizers", finalizers: [{ index, id, keys }] }); + return index; + }; + const stops = createStopWriter(arena, log, state.strings); + const owned: Tail[] = []; + + return { + log, + block: start, + local, + gone: () => ended, + goneError, + run, + idle: () => flying ?? settledTask, + busy: () => flying !== null, + owned, + completed: arrived => { + message = arrived; + const resolve = settle; + settle = null; + resolve?.(true); + }, + place, + unplace, + unplaced, + placed: () => placed - freeIndexes.length, + placeFinalizer, + finalizerAt: index => finalizers[index], + stops, + dropped: false, + wake, + rewind: () => { + if (retired.length > 0) freeRetired(); + if (!wrote || ended) return; + wrote = false; + const needed = Atomics.load(control, ringNeededWord); + if (needed > ring.bytes) { + arena.free(ring.offset, ring.bytes); + const bytes = Math.max(needed, 2 * ring.bytes); + ring = { offset: arena.allocate(bytes), bytes }; + } + placeRing(); + }, + end: () => { + ended = true; + const resolve = settle; + settle = null; + resolve?.(false); + }, + free: () => { + freeStops(stops); + for (const tail of [...owned, ...retired]) freeTail(state, tail); + arena.free(raw, blockBytes); + for (const offset of removedWords) if (offset !== undefined) arena.free(offset, lineBytes); + arena.free(ring.offset, ring.bytes); + if (payload.bytes > 0) arena.free(payload.offset, payload.bytes); + if (completion.bytes > 0) arena.free(completion.offset, completion.bytes); + }, + }; +}; + +/** Every key a leaf names in its declaration, so the thread finds the world's numbers for them. */ +export const keysOf = (leaf: Leaf): readonly symbol[] => { + const { components, lookup, resources, reduce } = leaf.declaration; + const keys = new Set([ + ...components?.read ?? [], ...components?.write ?? [], ...components?.has ?? [], ...components?.not ?? [], ...components?.added ?? [], ...components?.changed ?? [], + ...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? [], ...resources?.is ?? [], ...resources?.not ?? [], + ]); + for (const name in lookup) for (const key of lookupComponentsOf(lookup[name])) keys.add(key); + if (reduce !== undefined) for (const key of Object.getOwnPropertySymbols(reduce)) keys.add(key); + return [...keys].filter((key): key is symbol => typeof key === "symbol"); +}; + +/** Every key a finalizer names, so the thread finds the world's numbers for them: it looks up nothing. */ +export const finalizerKeysOf = (finalizer: FinalizerLeaf): readonly symbol[] => { + const { components, resources } = finalizer.declaration; + const keys = new Set([...components.read ?? [], ...components.has ?? [], ...components.not ?? [], ...resources?.read ?? []]); + return [...keys].filter((key): key is symbol => typeof key === "symbol"); +}; + +/** + * Why a leaf can't run on a joined thread, or null when it can: `up`, `order`, one entity, lookups through indexes with + * an object key part, which live on the world's thread, and indexes as change filters run only there. + */ +/** Why a leaf with an index as a change filter can't run on a joined thread. */ +export const entriesOnJoined = (leaf: Leaf, thread: string, index: string): string => + `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and when index "${index}" changed, a lookup's changed being true, which runs only on the world's thread.`; + +export const unplaceable = (leaf: Leaf, thread: string): string | null => { + if (leaf.declaration.up !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and declares up, which runs only on the world's thread.`; + if (leaf.declaration.order !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and orders its rows by an index, which runs only on the world's thread.`; + if (leaf.declaration.target !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and on one entity, which runs only on the world's thread.`; + const { components, resources } = leaf.declaration; + const string = [...components?.write ?? [], ...resources?.write ?? []].find(key => layoutOf(declarationOf(key)?.type).kind === "string"); + if (string !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and writes "${labelOf(string)}", a string, which only the world's thread writes.`; + for (const lookup of Object.values(leaf.declaration.lookup ?? {})) { + const index = indexOfLookup(lookup); + const object = index === undefined ? undefined : unsharedKeyOf(index.declaration.key); + if (index !== undefined && object !== undefined) { + return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and looks up through index "${labelOfItem(index)}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`; + } + if (index !== undefined && watchesEntries(lookup)) return entriesOnJoined(leaf, thread, labelOfItem(index)); + } + return null; +}; + +/** The records of a declaration's lookups through indexes, in the memory, by lookup name; and their release with the indexes. */ +const remoteIndexesOf = (state: State, lookup: Leaf["declaration"]["lookup"], acquired: IndexLookups): { readonly indexes: readonly RemoteIndex[]; readonly named: readonly (readonly [string, number])[]; readonly holding: IndexLookups } => { + if (acquired.byName.size === 0) return { indexes: [], named: [], holding: acquired }; + const indexes: RemoteIndex[] = []; + const named: (readonly [string, number])[] = []; + for (const [name, index] of acquired.byName) { + const declared = lookup?.[name]; + const watched = (declared === undefined ? [] : watchedBy(declared)).map(key => idOf(state, key)); + const record = state.memory.allocate(8 * recordWordsOf(index.sorted !== null, watched.length)); + indexes.push({ index, watched, record }); + named.push([name, record]); + } + const holding: IndexLookups = { + ...acquired, + release: () => { + acquired.release(); + for (const { index, watched, record } of indexes) state.memory.free(record, 8 * recordWordsOf(index.sorted !== null, watched.length)); + indexes.length = 0; + }, + }; + return { indexes, named, holding }; +}; + +/** + * A runner standing for a system placed on a joined thread: what the world's schedule needs of it, its matches for + * GPU readbacks, and its folds as its last task gave them. `since` is the tick it last ran at, wherever it ran. + */ +export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChannel, since = 0): Runner => { + const { declaration } = leaf; + const { components: query } = declaration; + const thread = threadOf(declaration, "System", leaf.name) ?? "world"; + const label = labelOfItem(leaf); + const problem = unplaceable(leaf, thread); + if (problem !== null) throw new Error(problem); + const lookups = remoteIndexesOf(state, declaration.lookup, acquireLookups(state, declaration.lookup)); + const index = channel.place(state, leaf, lookups.named); + const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(key => idOf(state, key)); + const excluded = (query?.not ?? []).map(key => idOf(state, key)); + const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); + const writes = (query?.write ?? []).map(key => idOf(state, key)); + const looks = [...new Set(Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []))].map(key => idOf(state, key)); + const targets = reductionTargets(state, declaration); + const folds = targets.map(target => new Float64Array(target.leaves.length)); + const matches: Archetype[] = []; + let checked = 0; + const matched = (): readonly Archetype[] => { + for (; checked < state.archetypes.length; checked++) { + const archetype = state.archetypes[checked]; + if (query !== undefined && !archetype.resource && !(archetype.meta && required.length === 0) && required.every(id => archetype.storageOf.has(id)) && !excluded.some(id => archetype.storageOf.has(id))) matches.push(archetype); + } + return matches; + }; + const remote: RemoteSystem = { + channel, index, label, + warning: { kind: "blocking", name: label, message: `System "${label}" runs on thread "${thread}", which a joined thread fills, so its tier waits for that thread.` }, + targets, folds, foldCount: folds.reduce((sum, leaves) => sum + leaves.length, 0), + changed: (query?.changed ?? []).map(key => idOf(state, key)), + watched: entityWatchesOf(declaration.lookup).map(key => idOf(state, key)), + archetypes: state.archetypes, + writes, + declaresInput: declaration.input !== undefined, + lastRun: since, + result: { flags: 0, segment: undefined, error: undefined }, + tail: null, + indexes: lookups.indexes, + }; + return { + kernel: null, + placement: "cpu", + touches, + writes, + looks, + resourceWrites: resourceWritesOf(state, declaration), + matched, + indexes: lookups.holding.uses, + load: async () => {}, + setup: () => null, + start: () => [], + targets, + finish: failed => failed || (remote.result.flags & ranFlag) === 0 || targets.length === 0 ? null : folds, + afterSync: null, + holdings: { ...holdingNothing({ unplace: () => channel.unplace(index) }), indexes: lookups.holding }, + remote, + joined: null, + lastRun: () => remote.lastRun, + restart: null, + }; +}; + +/** + * Before a task: a GPU kernel's writes stamp a whole column at once, which a thread's `changed` filters can't see, so + * the rows of a column stamped since the system last ran take the stamp. + */ +export const liftColumnStamps = (runner: Runner): void => { + const remote = runner.remote; + if (remote === null) return; + if (remote.changed.length > 0) for (const archetype of runner.matched()) liftColumns(archetype, remote.changed, remote.lastRun); + // An entity a lookup finds may be in any archetype. + if (remote.watched.length > 0) for (const archetype of remote.archetypes) liftColumns(archetype, remote.watched, remote.lastRun); +}; + +/** Gives the rows of an archetype's columns of `ids` stamped whole after `since` the column's stamp. */ +export const liftColumns = (archetype: Archetype, ids: readonly number[], since: number): void => { + for (const id of ids) { + const storage = archetype.storageById[id]; + if (storage === undefined || !storage.ticked || storage.columnChanged <= since) continue; + const stamp = storage.columnChanged; + for (let row = 0; row < archetype.count; row++) if (storage.changed[row] < stamp) storage.changed[row] = stamp; + for (let chunk = 0; chunk <= (archetype.count - 1) >> chunkShift; chunk++) if (storage.chunkChanged[chunk] < stamp) storage.chunkChanged[chunk] = stamp; + } +}; diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts new file mode 100644 index 000000000..7b92abb8c --- /dev/null +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Observe } from "../../observe/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +// Objects a joined thread fills, with the world and the thread on this one, joined through a MessageChannel. + +const point = Symbol("remoteTest/point"); +const caption = Symbol("remoteTest/caption"); +const widget = Symbol("remoteTest/widget"); +const kept = Symbol("remoteTest/kept"); +const guarded = Symbol("remoteTest/guarded"); +const handle = Symbol("remoteTest/handle"); +declare module "@adobe/ecs" { + export interface Threads { + remoteTest: true; + remoteTestFinalized: true; + } + export interface Components { + [point]: { type: number }; + [caption]: { type: { readonly text: string } | null; thread: "remoteTest" }; + [widget]: { type: Widget | null; thread: "remoteTest" }; + [kept]: { type: string }; + [guarded]: { type: Widget | null; thread: "remoteTestFinalized" }; + [handle]: { type: Widget | number | null; thread: "remoteTest" }; + } +} +/** A value that isn't data. */ +type Widget = { readonly draw: () => string }; +Component.register(point, { type: { type: "number" } }); +/** The caption's start, which a command here gives it by this value. */ +const none = { text: "none" }; +Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "remoteTest" }); +Component.register(widget, { thread: "remoteTest" }); +Component.register(kept, { type: { type: "string" } }); +/** A widget, or the number a kernel gives one, which only the world's thread could hold. */ +Component.register(handle, { thread: "remoteTest" }); +Component.register(guarded, { thread: "remoteTestFinalized" }); + +const spawn = System.create({ name: "remoteTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { + commands.spawn([point, caption, widget, kept])({ [point]: input.x, [kept]: `k${input.x}`, [widget]: null }); +}); +const nullCaptions = System.create({ name: "remoteTest/nullCaptions", components: { has: [caption, point] } }, (row, { commands }) => { + commands.add(row.entity, caption, null); +}); +const restartCaptions = System.create({ name: "remoteTest/restartCaptions", components: { has: [caption, point] } }, (row, { commands }) => { + commands.add(row.entity, caption, none); +}); +const dropCaptions = System.create({ name: "remoteTest/dropCaptions", components: { has: [caption] } }, (row, { commands }) => { + commands.remove(row.entity, caption); +}); +const giveCaption = System.create({ name: "remoteTest/giveCaption", components: { has: [point] } }, (row, { commands }) => { + commands.add(row.entity, caption, { text: "a caption only the thread gives" }); +}); +const spawnCaptioned = System.create({ name: "remoteTest/spawnCaptioned" }, ({ commands }) => { + commands.spawn([point, caption])({ [point]: 1, [caption]: { text: "a caption only the thread gives" } }); +}); +/** A kernel giving the thread's objects null, which they take, and a number, which they don't. */ +const nullCaptionsKernel = System.create({ name: "remoteTest/nullCaptionsKernel", kernel: "cpu", components: { has: [caption], read: [point] } }, (row, { commands }) => { + if (row[point] > 0) commands.add(row.entity, caption, null); +}); +const numberHandlesKernel = System.create({ name: "remoteTest/numberHandlesKernel", kernel: "cpu", components: { read: [point] } }, (row, { commands }) => { + commands.add(row.entity, handle, row[point]); +}); +/** Reaches its commands, so it isn't terminal: its tier blocks rather than running as a tail. */ +const readCaptions = System.create({ name: "remoteTest/readCaptions", components: { read: [caption] } }, (row, { commands }) => { + if (row[caption]?.text === "never") commands.despawn(row.entity); +}); +const idle = System.create({ name: "remoteTest/idle" }, () => {}); +const after = System.create({ name: "remoteTest/after" }, () => {}); +Finalizer.create({ name: "remoteTest/releaseGuarded", components: { read: [guarded] } }, () => {}); + +/** A world shared with a thread on this one, which claims `threads`. */ +const joined = (threads: readonly ("remoteTest" | "remoteTestFinalized")[] = ["remoteTest"]): { readonly world: CreatedWorld; readonly remote: World } => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads }); + return { world, remote }; +}; + +/** The first value an observer gives that `accept` takes. */ +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +/** Once the thread shared last has status `status`. */ +const statusOf = (world: CreatedWorld, status: "joined" | "gone") => + until(world.threads, threads => threads.at(-1)?.status === status); + +/** The entity a run of `spawn` spawned. */ +const spawnOne = async (world: World, x: number): Promise => { + await world.run(spawn, { x }); + const [entity] = await Observe.toPromise(Observe.select(world, { required: [point], where: { [point]: x } })); + return entity; +}; + +describe("objects a joined thread fills", () => { + // The cases of observers reading them are in `observe-joined-cases.test.ts`. + it("take from the world's thread only their start or null, in commands and spawns", async () => { + const { world, remote } = joined(); + await statusOf(world, "joined"); + await spawnOne(world, 2); + const message = '"remoteTest/caption" lives on thread "remoteTest", which a joined thread fills, so a command here gives it only its start or null.'; + expect((await runReported(world, giveCaption)).failures.map(failure => failure.message)).toEqual([message]); + expect((await runReported(world, spawnCaptioned)).failures.map(failure => failure.message)).toEqual([message]); + expect(await Observe.toPromise(Observe.count(world, { required: [caption] }))).toBe(1); + await remote.dispose(); + await world.dispose(); + }); + it("take from a kernel only their start or null, its command giving another dropping and its system failing", async () => { + const { world, remote } = joined(); + await statusOf(world, "joined"); + const entity = await spawnOne(world, 6); + const listed = Observe.entity(remote, entity, { required: [caption, widget] }); + expect(await until(listed, value => value !== null)).toEqual({ [caption]: none, [widget]: null }); + expect((await runReported(world, nullCaptionsKernel)).failures).toEqual([]); + expect(await until(listed, value => value?.[caption] === null)).toEqual({ [caption]: null, [widget]: null }); + const report = await runReported(world, numberHandlesKernel); + expect(report.failures.map(failure => [failure.name, failure.message])).toEqual([["remoteTest/numberHandlesKernel", '"remoteTest/handle" lives on thread "remoteTest", which a joined thread fills, so a command here gives it only its start or null.']]); + expect(report.dropped).toBe(1); + await remote.dispose(); + await world.dispose(); + }); + it("are read by systems that run on the thread, their tiers warned of", async () => { + const { world, remote } = joined(); + await statusOf(world, "joined"); + await spawnOne(world, 3); + expect(await runReported(world, readCaptions)).toEqual({ + failures: [], skipped: [], dropped: 0, + warnings: [{ kind: "blocking", name: "remoteTest/readCaptions", message: 'System "remoteTest/readCaptions" runs on thread "remoteTest", which a joined thread fills, so its tier waits for that thread.' }], + }); + await remote.dispose(); + await world.dispose(); + }); + it("come back to the world's thread when the thread that filled them is gone, as the start or null each held", async () => { + const { world, remote } = joined(); + await statusOf(world, "joined"); + const first = await spawnOne(world, 4); + await world.run(nullCaptions); + const second = await spawnOne(world, 5); + await remote.dispose(); + await statusOf(world, "gone"); + await world.run(after); + expect(await Observe.toPromise(Observe.entity(world, first, { required: [caption, widget] }))).toEqual({ [caption]: null, [widget]: null }); + expect(await Observe.toPromise(Observe.entity(world, second, { required: [caption] }))).toEqual({ [caption]: none }); + expect((await runReported(world, readCaptions)).failures).toEqual([]); + await world.dispose(); + }); + it("refuse a claim of a thread whose finalizers or systems the joined thread can't run, and take one whose systems it imported", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(readCaptions); + const taken = joined(); + await statusOf(taken.world, "joined"); + expect((await runReported(taken.world, readCaptions)).warnings.map(warning => warning.name)).toEqual(["remoteTest/readCaptions"]); + await taken.remote.dispose(); + await taken.world.dispose(); + // Declared in a function, so the build gives it no id, and another thread can't name it. + const unnamed = System.create({ name: "remoteTest/unnamed", components: { read: [caption] } }, row => { void row[caption]; }); + await world.run(unnamed); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["remoteTest"] }); + await expect(remote.run(idle)).rejects.toThrow('System "remoteTest/unnamed" runs on thread "remoteTest" and has no id, so a joined thread can\'t run it'); + await statusOf(world, "gone"); + await remote.dispose(); + const other = joined(["remoteTestFinalized"]); + // Declared without a binding, so the build gives it no id. + await expect(other.remote.run(idle)).rejects.toThrow('Finalizer "remoteTest/releaseGuarded" runs on thread "remoteTestFinalized" and has no id, so a joined thread can\'t run it'); + await other.remote.dispose(); + await other.world.dispose(); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/remote.ts b/packages/ecs/src/implementation/world/remote.ts new file mode 100644 index 000000000..101fec406 --- /dev/null +++ b/packages/ecs/src/implementation/world/remote.ts @@ -0,0 +1,125 @@ +import { declarationOf, labelOf, threadOfDeclaration } from '../../interface/registry/index.js'; +import { makeLocal, makeRemote } from './archetype.js'; +import type { CommandBuffer } from './buffer.js'; +import { flushBetween } from './layout-directory.js'; +import { nullTag, tagOf, type MoveLog } from './move-log.js'; +import { startTicking, type State } from './state.js'; + +// Objects a joined thread fills: the world's storages for them hold each row's tag in place of its value, and the +// archetypes holding them append their moves to the thread's log (`move-log.ts`). + +/** The logical thread a component's or resource's objects live on, or null for a struct. */ +const threadOfComponent = (state: State, id: number): string | null => { + const declaration = declarationOf(state.keys[id]); + return declaration === undefined ? null : threadOfDeclaration(declaration); +}; + +/** Gives a component a joined thread fills to its log, in every archetype and batch holding it. */ +const give = (state: State, id: number, log: MoveLog): void => { + state.remote[id] = log; + startTicking(state, [id]); + for (const archetype of state.archetypes) { + const storage = archetype.storageById[id]; + if (storage !== undefined) makeRemote(state.memory, archetype, storage, log); + } + for (const batch of state.batches) for (const column of batch.columns) if (column.id === id) column.remote = remoteError(state, id).message; + log.announce(id); +}; + +/** + * A component the world just met, whose logical thread a joined thread claims: its objects are that thread's. No + * archetype holds it yet, so nothing allocates. + */ +export const claimComponent = (state: State, id: number): void => { + const thread = threadOfComponent(state, id); + const log = thread === null ? undefined : state.claims.get(thread); + if (log === undefined) return; + state.remote[id] = log; + state.ticked.add(id); + log.announce(id); +}; + +/** + * Between runs: a joined thread claims logical threads, so their components' objects are its own. Rows holding them + * start there at their starts; the world's values are released. Their ticks are kept, so the thread rebuilds them. + */ +export const claimThreads = (state: State, log: MoveLog, threads: readonly string[]): void => { + for (const thread of threads) state.claims.set(thread, log); + state.logs.push(log); + state.claimed = true; + state.counting = true; + for (let id = 0; id < state.keys.length; id++) { + const thread = threadOfComponent(state, id); + if (thread !== null && state.claims.get(thread) === log) give(state, id, log); + } + if (state.directory !== null) state.directory.guarded = true; + flushBetween(state.directory, state.bumped, state.archetypes, state.entities, state.resourceEntity, state.counted); +}; + +/** + * Between runs: a joined thread that's gone gives back the logical threads it claimed. Rows hold the value each tag + * names, `null` or the start: a staged value was the thread's own. + */ +export const releaseThreads = (state: State, log: MoveLog): void => { + const index = state.logs.indexOf(log); + if (index < 0) return; + state.logs.splice(index, 1); + for (const [thread, owner] of [...state.claims]) if (owner === log) state.claims.delete(thread); + for (let id = 0; id < state.remote.length; id++) { + if (state.remote[id] !== log) continue; + state.remote[id] = undefined; + for (const archetype of [...log.archetypes]) { + const storage = archetype.storageById[id]; + if (storage !== undefined) makeLocal(state.memory, archetype, storage, state.starts[id]); + } + for (const batch of state.batches) for (const column of batch.columns) if (column.id === id) column.remote = null; + } + state.claimed = state.logs.length > 0; + state.counting = state.claimed || state.observer.count > 0; + if (state.directory !== null) state.directory.guarded = state.claimed; + // Finalizers on the threads it gave back activate again. + state.unplacedSeen.clear(); + state.activation.met = true; + flushBetween(state.directory, state.bumped, state.archetypes, state.entities, state.resourceEntity, state.counted); +}; + +export const remoteError = (state: State, id: number): Error => + new Error(`"${labelOf(state.keys[id])}" lives on thread "${threadOfComponent(state, id) ?? "world"}", which a joined thread fills, so a command here gives it only its start or null.`); + +/** + * For a system's commands, once a joined thread fills objects: the first error of a value another thread's object can't + * take, with each such command dropped, by naming no entity, and each such staged spawn's value its start. Commands of + * the joined thread whose log is `own` give its own objects slots it staged. + */ +export const checkCommands = (state: State, buffer: CommandBuffer, own: MoveLog | null = null): Error | undefined => { + let error: Error | undefined; + const { ids, values, starts, entities } = buffer; + for (let command = 0; command < buffer.count; command++) { + const end = command + 1 < buffer.count ? starts[command + 1] : buffer.named; + for (let k = starts[command]; k < end; k++) { + const id = ids[k]; + const log = state.remote[id]; + if (log === undefined) continue; + const tag = tagOf(values[k], state.starts[id]); + if (tag >= 0 && (tag <= nullTag || log === own)) continue; + error ??= remoteError(state, id); + entities[command] = 0; + break; + } + } + for (let s = 0; s < buffer.stagedCount; s++) { + const { batch, start, end } = buffer.staged[s]; + for (const column of batch.columns) { + const log = state.remote[column.id]; + if (log === undefined) continue; + const begin = state.starts[column.id]; + for (let row = start; row < end; row++) { + const tag = tagOf(column.values[row], begin); + if (tag >= 0 && (tag <= nullTag || log === own)) continue; + error ??= remoteError(state, column.id); + column.values[row] = begin; + } + } + } + return error; +}; diff --git a/packages/ecs/src/implementation/world/removal-worker.fixture.ts b/packages/ecs/src/implementation/world/removal-worker.fixture.ts new file mode 100644 index 000000000..32a992353 --- /dev/null +++ b/packages/ecs/src/implementation/world/removal-worker.fixture.ts @@ -0,0 +1,20 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { first, firstTail, second, secondTail, setPause, ticking } from "./remove.test-helper.js"; + +/** A thread joining a world through the port it's sent, claiming "removeTest", whose systems it imports. */ + +/** Named, so the bundle keeps the module declaring them: the package is free of side effects. */ +const imported = [first, second, firstTail, secondTail, ticking, setPause]; + +type Port = MessagePort | NodeMessagePort; +export type ToRemovalWorker = { readonly kind: "join"; readonly port: Port }; +export type FromRemovalWorker = { readonly kind: "joined"; readonly systems: number }; + +const channel = scopeChannel(); +channel.onMessage(message => { + if ("protocol" in message) return; + join(message.port, { threads: ["removeTest"] }); + channel.post({ kind: "joined", systems: imported.length }); +}); diff --git a/packages/ecs/src/implementation/world/removal.cases.ts b/packages/ecs/src/implementation/world/removal.cases.ts new file mode 100644 index 000000000..12951ace4 --- /dev/null +++ b/packages/ecs/src/implementation/world/removal.cases.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./removal.fixture.js"; + +/** Removing systems placed on a joined worker: the cases, over the fixture built for node or from source. */ +export const removalCases = (load: () => Promise) => describe("removing systems on a joined worker", () => { + it("never runs a system removed while the worker runs a task holding it, and both threads drop it", async () => { + const fixture = await load(); + const { atRemove, after, placed, left, none } = await fixture.removedMidTask(); + expect(atRemove).toBeGreaterThanOrEqual(2); + // The task in flight held `second` after `first`, which the worker ran no more of once its word was set. + expect(after).toBe(atRemove); + expect(placed).toEqual([2]); + expect(left).toEqual([1]); + expect(none).toEqual([0]); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/removal.fixture.ts b/packages/ecs/src/implementation/world/removal.fixture.ts new file mode 100644 index 000000000..0d40192a1 --- /dev/null +++ b/packages/ecs/src/implementation/world/removal.fixture.ts @@ -0,0 +1,95 @@ +/// +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import type { FromRemovalWorker, ToRemovalWorker } from "./removal-worker.fixture.js"; +import { first, second, secondRuns, setPause, ticking } from "./remove.test-helper.js"; + +/** Scenarios of removing systems placed on a joined worker, run built in node and from source in chromium. */ + +type Worker = { readonly ask: (message: ToRemovalWorker, transfer: readonly unknown[]) => Promise; readonly terminate: () => Promise }; + +const workerFile = "./removal-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./removal-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => { if (!("protocol" in event.data)) resolve(event.data); }; + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer) => new Promise(resolve => { + const reply = (data: FromRemovalWorker): void => { + if ("protocol" in data) return; + worker.off("message", reply); + resolve(data); + }; + worker.on("message", reply); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + }; +}; + +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +const later = (milliseconds: number): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)); + +/** + * Removes `second` from the world's thread while the joined worker runs `first`, whose task holds `second` too: + * `second`'s runs when `remove` returned and once ticks went on, and the indexes the world holds on the worker before + * removing, after, and once `first` is removed too. + */ +export const removedMidTask = async () => { + const world: CreatedWorld = createWorld({ [workers]: 0, [always]: true }); + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + await worker.ask({ kind: "join", port: port2 }, [port2]); + await until(world.threads, threads => threads.at(-1)?.status === "joined"); + try { + world.add(first); + world.add(second); + world.add(ticking); + await until(Observe.resource(world, secondRuns), runs => runs >= 2); + const placed = (await world.tables()).joined; + // `first` spins for 200 ms in each task from the next tick on; 50 ms into the first of them, `second` is removed. + await world.run(setPause, { milliseconds: 200 }); + const removed = new Promise(resolve => setTimeout(() => { + world.remove(second); + resolve(Observe.toPromise(Observe.resource(world, secondRuns))); + }, 50)); + const atRemove = await removed; + await world.run(setPause, { milliseconds: 0 }); + const tick = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await until(Observe.resource(world, clock), value => value.tick >= tick + 10); + const after = await Observe.toPromise(Observe.resource(world, secondRuns)); + let left = (await world.tables()).joined; + for (let k = 0; k < 100 && left[0] !== 1; k++) left = (await later(10), await world.tables()).joined; + world.remove(first); + let none = (await world.tables()).joined; + for (let k = 0; k < 100 && none[0] !== 0; k++) none = (await later(10), await world.tables()).joined; + return { atRemove, after, placed, left, none }; + } + finally { + await worker.terminate(); + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/remove-joined.test.ts b/packages/ecs/src/implementation/world/remove-joined.test.ts new file mode 100644 index 000000000..95fca1dc2 --- /dev/null +++ b/packages/ecs/src/implementation/world/remove-joined.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { tablesOf } from "./join.js"; +import type { LoopPlatform } from "./loop.js"; +import { calls, first, firstTail, removal, removerTail, second, secondTail, ticking } from "./remove.test-helper.js"; + +// Removing systems placed on a joined thread, with the world and the thread on this one, joined through a MessageChannel. + +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +/** + * Frames a millisecond apart, on timers, so messages between the threads, on ports of this one, are handled between + * ticks: back to back, through a port, ticks would hold every other port's messages until they pause. + */ +const timerFrames = { frame: (callback: (time: number) => void) => { + const handle = setTimeout(() => callback(performance.now()), 1); + return () => clearTimeout(handle); +} }; + +/** + * Timer frames until `immediate` is set, then each frame as soon as the last tick ends, before the thread on this one + * runs that tick's tails: the next tick waits for them, and their bodies run while it does. + */ +const switchableFrames = (): { immediate: boolean; readonly frame: LoopPlatform["frame"] } => { + const frames = { + immediate: false, + frame: (callback: (time: number) => void): (() => void) => { + if (!frames.immediate) return timerFrames.frame(callback); + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) callback(performance.now()); + }); + return () => { cancelled = true; }; + }, + }; + return frames; +}; + +/** A world ticking on `frames`, a millisecond apart by default, shared with a thread on this one claiming "removeTest". */ +const joined = async (frames: Pick = timerFrames): Promise<{ readonly world: CreatedWorld; readonly remote: World; readonly ticks: (count: number) => Promise; readonly close: () => Promise }> => { + const world = createWorld({ [workers]: 0, [always]: true }, { loop: frames }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["removeTest"] }); + await until(world.threads, threads => threads.at(-1)?.status === "joined"); + removal.world = world; + world.add(ticking); + const ticks = async (count: number): Promise => { + const from = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await until(Observe.resource(world, clock), ({ tick }) => tick >= from + count); + }; + const close = async (): Promise => { + removal.world = null; + removal.armed = false; + removal.target = null; + await remote.dispose(); + await world.dispose(); + }; + return { world, remote, ticks, close }; +}; + +/** What both threads hold of systems placed on the joined one: the world's indexes there, and the thread's systems. */ +const held = async (world: CreatedWorld, remote: World): Promise => + [(await world.tables()).joined[0] ?? 0, tablesOf.get(remote)?.().systems ?? -1]; + +/** + * What both threads hold once they agree on `expected`, or after 200 ticks: the thread drops a removed system as its + * message arrives, and the world places its index again once the thread's answer does. + */ +const settled = async (world: CreatedWorld, remote: World, ticks: (count: number) => Promise, expected: readonly [number, number]): Promise => { + for (let tick = 0; tick < 200; tick++) { + const now = await held(world, remote); + if (now[0] === expected[0] && now[1] === expected[1]) return now; + await ticks(1); + } + return held(world, remote); +}; + +describe("removing systems on a joined thread", () => { + for (const [kind, one, two, ran] of [ + ["a blocking tier's", first, second, () => [calls.first, calls.second]], + ["a tail's", firstTail, secondTail, () => [calls.firstTail, calls.secondTail]], + ] as const satisfies readonly (readonly [string, System, System, () => readonly number[]])[]) { + it(`never runs ${kind} body once removed, a task already posted with it included, and both threads drop what they held`, async () => { + const { world, remote, ticks, close } = await joined(); + world.add(one); + world.add(two); + await ticks(3); + expect(await held(world, remote)).toEqual([2, 2]); + const [ranOne, ranTwo] = ran(); + expect(ranTwo).toBeGreaterThan(0); + // The first removes the second from the task both are in, which the thread then runs no more of. + removal.armed = true; + await ticks(4); + removal.armed = false; + const [laterOne, laterTwo] = ran(); + expect(laterOne).toBeGreaterThan(ranOne); + expect(laterTwo).toBe(ranTwo); + expect(await settled(world, remote, ticks, [1, 1])).toEqual([1, 1]); + world.remove(one); + expect(await settled(world, remote, ticks, [0, 0])).toEqual([0, 0]); + await close(); + }); + } + + it("posts no task naming a tail removed while the next tick waited for its thread to finish the last", async () => { + const frames = switchableFrames(); + const { world, remote, ticks, close } = await joined(frames); + world.add(firstTail); + world.add(secondTail); + await ticks(3); + expect(await held(world, remote)).toEqual([2, 2]); + // From the next tick on, each starts as the last ends and waits for the last's tails, which run meanwhile: the + // tails of the tick `ticks` sees now are still posted, and the first of them removes the second. + frames.immediate = true; + await ticks(1); + const ran = calls.secondTail; + removal.armed = true; + await ticks(3); + removal.armed = false; + frames.immediate = false; + expect(calls.secondTail).toBe(ran); + expect(await settled(world, remote, ticks, [1, 1])).toEqual([1, 1]); + world.remove(firstTail); + expect(await settled(world, remote, ticks, [0, 0])).toEqual([0, 0]); + await close(); + }); + + it("posts no task naming a blocking tier's system removed while the next tick waited for its thread to finish the last", async () => { + const frames = switchableFrames(); + const { world, remote, ticks, close } = await joined(frames); + world.add(first); + world.add(second); + world.add(removerTail); + await ticks(3); + expect(await held(world, remote)).toEqual([3, 3]); + // As above: the next tick waits for the tail of the tick seen now, which removes `second`, before it posts the + // task its tiers are one of. + frames.immediate = true; + await ticks(1); + const ran = calls.second; + removal.target = second; + await ticks(3); + frames.immediate = false; + expect(removal.target).toBe(null); + expect(calls.second).toBe(ran); + expect(await settled(world, remote, ticks, [2, 2])).toEqual([2, 2]); + world.remove(first); + world.remove(removerTail); + expect(await settled(world, remote, ticks, [0, 0])).toEqual([0, 0]); + await close(); + }); + + it("places an index again only once the thread dropped what it held for it", async () => { + const { world, remote, ticks, close } = await joined(); + world.add(first); + for (let k = 0; k < 20; k++) { + world.add(second); + await ticks(1); + world.remove(second); + await ticks(1); + } + expect(await settled(world, remote, ticks, [1, 1])).toEqual([1, 1]); + expect(() => remote.remove(first)).toThrow("on the world's thread"); + await close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts new file mode 100644 index 000000000..49c5617cf --- /dev/null +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -0,0 +1,84 @@ +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; + +// Keys and systems both threads of the removal tests import: systems on thread "removeTest", which a joined thread +// fills, the first of each pair removing the second from the task they run in. + +export const mark = Symbol("removeTest/mark"); +export const pause = Symbol("removeTest/pause"); +export const secondRuns = Symbol("removeTest/secondRuns"); +declare module "@adobe/ecs" { + export interface Threads { + removeTest: true; + } + export interface Resources { + [mark]: { type: { readonly text: string }; thread: "removeTest" }; + [pause]: { type: number }; + [secondRuns]: { type: number }; + } +} +/** An object resource of the thread, which places the systems reading it there. */ +Resource.register(mark, { type: { type: "object", properties: { text: { type: "string" } }, default: { text: "m" } }, thread: "removeTest" }); +/** How long, in milliseconds, `first` and `firstTail` spin before they return. */ +Resource.register(pause, { type: { type: "number", default: 0 } }); +/** How many times `second` ran, wherever it ran. */ +Resource.register(secondRuns, { type: { type: "integer", default: 0 } }); + +/** How many times each system's body was called on this thread. */ +export const calls = { first: 0, second: 0, firstTail: 0, secondTail: 0 }; + +/** + * Where systems on this thread remove others: a world on this thread, for tests on one thread; and what `removerTail` + * removes next. + */ +export const removal: { world: World | null; armed: boolean; target: System | null } = { world: null, armed: false, target: null }; + +const spin = (milliseconds: number): void => { + const end = performance.now() + milliseconds; + while (performance.now() < end); +}; + +/** Blocking, since its body reaches `commands`: a tier on the thread, and with `second`, one task. */ +export const first = System.create({ name: "removeTest/first", resources: { read: [mark, pause] } }, ({ resources, commands }) => { + void commands; + calls.first += resources[mark].text.length; + spin(resources[pause]); + if (removal.armed) removal.world?.remove(second); +}); + +export const second = System.create({ name: "removeTest/second", resources: { read: [mark], write: [secondRuns] }, after: [first] }, ({ resources, commands }) => { + void commands; + calls.second += resources[mark].text.length; + resources[secondRuns] += 1; +}); + +/** Terminal, so a tail, run with `secondTail` as one task after the tick's sync point. */ +export const firstTail = System.create({ name: "removeTest/firstTail", resources: { read: [mark, pause] } }, ({ resources }) => { + calls.firstTail += resources[mark].text.length; + spin(resources[pause]); + if (removal.armed) removal.world?.remove(secondTail); +}); + +/** Terminal, so a tail: removes `removal.target`, once, when it's set. */ +export const removerTail = System.create({ name: "removeTest/removerTail", resources: { read: [mark] } }, ({ resources }) => { + void resources[mark]; + const { target } = removal; + removal.target = null; + if (target !== null) removal.world?.remove(target); +}); + +export const secondTail = System.create({ name: "removeTest/secondTail", resources: { read: [mark] }, after: [firstTail] }, ({ resources }) => { + calls.secondTail += resources[mark].text.length; +}); + +/** + * On the world's thread, added while the others come and go, so ticks go on; after `second`, so the tiers of `first` and + * `second` are one task there. + */ +export const ticking = System.create({ name: "removeTest/ticking", after: [second] }, () => {}); + +/** Sets how long `first` and `firstTail` spin. */ +export const setPause = System.create({ name: "removeTest/setPause", input: { type: "object", properties: { milliseconds: { type: "number" } } }, resources: { write: [pause] } }, ({ input, resources }) => { + resources[pause] = input.milliseconds; +}); diff --git a/packages/ecs/src/implementation/world/remove.test.ts b/packages/ecs/src/implementation/world/remove.test.ts new file mode 100644 index 000000000..a99c902eb --- /dev/null +++ b/packages/ecs/src/implementation/world/remove.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import { tablesAfterCollecting } from "./lifetime.test-helper.js"; +import { backToBack } from "./loop.js"; + +const value = Symbol("removeTest/value"); +const parent = Symbol("removeTest/parent"); +const shared = Symbol("removeTest/shared"); + +declare module "@adobe/ecs" { + interface Components { + [value]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [shared]: { type: number }; + } +} + +Component.register(value, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(shared, { type: { type: "number" } }); + +/** How many times each system's body was called. */ +const calls = { kept: 0, held: 0, alone: 0, other: 0, late: 0, self: 0, writer: 0, reader: 0 }; +/** The world the systems that remove others act on. */ +let current: World | null = null; +/** What `selfRemoving` removes, once its body has been called this many times. */ +let removeAfter = Infinity; + +const seed = System.create({ name: "removeTest/seed" }, ({ commands }) => { + const root = commands.spawn([value, shared])({ [value]: 0, [shared]: 0 }); + for (let i = 1; i < 10; i++) commands.spawn([value, parent, shared])({ [value]: i, [parent]: root, [shared]: 0 }); +}); + +/** Run before tables are taken, so `value` and `parent` keep ticks then, as they do once any system filters on them. */ +const ticksValue = System.create({ name: "removeTest/ticksValue", components: { added: [value, parent] } }, () => {}); + +/** Added for the whole of each test, so ticks go on. */ +const kept = System.create({ name: "removeTest/kept" }, () => { calls.kept++; }); + +/** Holds what systems can on the world's thread: an `up` order, a change filter's count, command buffers. */ +const held = System.create({ name: "removeTest/held", components: { read: [value], changed: [value] }, up: { through: parent, read: [value] } }, (row, { up }) => { + calls.held++; + void (row[value] + (up?.[value] ?? 0)); +}); + +const alone = System.create({ name: "removeTest/alone" }, () => { calls.alone++; }); +const other = System.create({ name: "removeTest/other" }, () => { calls.other++; }); +const pair = System.create({ name: "removeTest/pair", systems: [alone, other] }); + +/** In a later tier than `remover`, which removes it during the tick. */ +const late = System.create({ name: "removeTest/late", components: { read: [value] } }, row => { calls.late += row[value] >= 0 ? 1 : 0; }); +const remover = System.create({ name: "removeTest/remover", before: [late] }, () => { current?.remove(late); }); + +/** Removes itself partway through its rows. */ +const selfRemoving = System.create({ name: "removeTest/selfRemoving", components: { read: [value] } }, row => { + calls.self += row[value] >= 0 ? 1 : 0; + if (calls.self === removeAfter) current?.remove(selfRemoving); +}); + +/** Ordered only through `phase`: removing `phase` would leave them conflicting with no order between them. */ +const phase = System.create({ name: "removeTest/phase" }, () => {}); +const writer = System.create({ name: "removeTest/writer", components: { write: [shared] }, before: [phase] }, row => { calls.writer++; row[shared] += 1; }); +const reader = System.create({ name: "removeTest/reader", components: { read: [shared] }, after: [phase] }, row => { calls.reader++; void row[shared]; }); + +/** A world ticking back to back once something is added, with `kept` added. */ +const ticking = async (): Promise<{ readonly world: CreatedWorld; readonly ticks: (count: number) => Promise; readonly close: () => Promise }> => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + current = world; + await world.run(seed); + world.add(kept); + /** Waits for `count` more ticks. */ + const ticks = async (count: number): Promise => { + const first = (await Observe.toPromise(Observe.resource(world, clock))).tick; + await new Promise(resolve => { + const stop = Observe.resource(world, clock)(({ tick }) => { + if (tick < first + count) return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + }; + await ticks(2); + return { world, ticks, close: async () => { await world.dispose(); driver.close(); current = null; } }; +}; + +describe("world.remove", () => { + it("never calls a removed system's body again, and releases what it held", async () => { + const { world, ticks, close } = await ticking(); + await world.run(ticksValue); + const before = await tablesAfterCollecting(world); + world.add(held); + await ticks(3); + expect(calls.held).toBeGreaterThan(0); + world.remove(held); + const removed = calls.held; + await ticks(3); + expect(calls.held).toBe(removed); + expect(await tablesAfterCollecting(world)).toEqual(before); + await close(); + }); + + it("keeps a system added alone and within an added composite until both are removed", async () => { + const { world, ticks, close } = await ticking(); + world.add(pair); + world.add(alone); + await ticks(2); + world.remove(alone); + const [aloneThen, otherThen] = [calls.alone, calls.other]; + await ticks(2); + expect(calls.alone).toBeGreaterThan(aloneThen); + world.remove(pair); + const [aloneAfter, otherAfter] = [calls.alone, calls.other]; + await ticks(2); + expect([calls.alone, calls.other]).toEqual([aloneAfter, otherAfter]); + expect(otherAfter).toBeGreaterThan(otherThen); + await close(); + }); + + it("does nothing for a value that isn't added, and throws for one no constructor returned", async () => { + const { world, ticks, close } = await ticking(); + world.remove(other); + world.remove(kept); + world.remove(kept); + expect(() => world.remove({ name: "not a system" })).toThrow("isn't a system"); + world.add(kept); + await ticks(1); + await close(); + }); + + it("throws, removing nothing, when the systems left would conflict with no order between them", async () => { + const { world, ticks, close } = await ticking(); + world.add(writer); + world.add(phase); + world.add(reader); + await ticks(1); + expect(() => world.remove(phase)).toThrow('both access "removeTest/shared"'); + const [written, read] = [calls.writer, calls.reader]; + await ticks(2); + expect(calls.writer).toBeGreaterThan(written); + expect(calls.reader).toBeGreaterThan(read); + world.remove(reader); + world.remove(phase); + world.remove(writer); + await close(); + }); + + it("skips a system removed during a tick in progress from its next tier on", async () => { + const { world, ticks, close } = await ticking(); + world.add(late); + await ticks(2); + const before = calls.late; + expect(before).toBeGreaterThan(0); + // In the tick `remover` runs, `late` follows it in a later tier. + world.add(remover); + await ticks(3); + expect(calls.late).toBe(before); + world.remove(remover); + await close(); + }); + + it("calls no row of a system that removed itself partway through its rows", async () => { + const { world, ticks, close } = await ticking(); + calls.self = 0; + removeAfter = 3; + world.add(selfRemoving); + await ticks(3); + expect(calls.self).toBe(3); + removeAfter = Infinity; + await close(); + }); + + it("skips a removed system in a run of a composite holding it that was queued before", async () => { + const { world, close } = await ticking(); + world.add(pair); + const ran = world.run(pair); + world.remove(pair); + const [aloneThen, otherThen] = [calls.alone, calls.other]; + await ran; + expect([calls.alone, calls.other]).toEqual([aloneThen, otherThen]); + // Run again, it's registered anew. + await world.run(pair); + expect([calls.alone, calls.other]).toEqual([aloneThen + 1, otherThen + 1]); + await close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/reports.test-helper.ts b/packages/ecs/src/implementation/world/reports.test-helper.ts new file mode 100644 index 000000000..d45682f00 --- /dev/null +++ b/packages/ecs/src/implementation/world/reports.test-helper.ts @@ -0,0 +1,117 @@ +import { Report } from "../../interface/report/report.js"; +import { retainedRuns } from "./reports.js"; +import { System } from "../../interface/system/system.js"; +import type { Input } from "../../interface/system/input.js"; +import type { World } from "../../interface/world/world.js"; + +/** A run's report as data: its errors, empty when it has none, and what it dropped. */ +export type Reported = { + readonly failures: readonly Report.Failure[]; + readonly skipped: readonly string[]; + readonly dropped: number; + readonly warnings: readonly Report.Warning[]; +}; + +/** By world, its reader of reports and the reads asked of it, in the order their runs run: each the run whose report it wants, and what it found. */ +type Reader = { readonly system: System; readonly asked: { readonly run: number; found: Reported | null; values: Report[typeof Report.run] | null }[] }; +const readers = new WeakMap(); + +const none: readonly never[] = []; + +const readerOf = (world: World): Reader => { + const known = readers.get(world); + if (known !== undefined) return known; + const asked: Reader["asked"] = []; + const system = System.create({ name: "test/readReport", observer: true, components: { read: [Report.run] }, lookup: { errorsOf: { read: [Report.errors] } } }, (row, { lookup }) => { + const read = asked[0]; + const values = row[Report.run]; + if (read === undefined || (read.run >= 0 ? values.number !== read.run : values.number <= (read.values?.number ?? -1))) return; + const errors = lookup.errorsOf(row.entity)?.[Report.errors] ?? null; + read.values = values; + read.found = errors === null + ? { failures: none, skipped: none, dropped: values.dropped, warnings: none } + : { failures: errors.failures, skipped: errors.skipped, dropped: values.dropped, warnings: errors.warnings }; + }); + const reader = { system, asked }; + readers.set(world, reader); + return reader; +}; + +const readOf = async (world: World, run: number) => { + const { system, asked } = readerOf(world); + const read: Reader["asked"][number] = { run, found: null, values: null }; + asked.push(read); + try { + await world.run(system); + } + finally { + const left = asked.indexOf(read); + if (left >= 0) asked.splice(left, 1); + } + return read; +}; + +/** Run `run`'s report, read once by an observer system's run over every report; throws when there's none. */ +export const reportOf = async (world: World, run: number): Promise => { + const { found } = await readOf(world, run); + if (found === null) throw new Error(`Run ${run} has no report.`); + return found; +}; + +/** Run `run`'s `Report.run`; throws when it has no report. */ +export const runOf = async (world: World, run: number): Promise => { + const { values } = await readOf(world, run); + if (values === null) throw new Error(`Run ${run} has no report.`); + return values; +}; + +/** + * Runs a system, then reads the next run's report, which holds what the system's run found after its own was written: + * what its observer pass threw. + */ +export const runThenNext = async (world: World, system: System, ...input: Input>): Promise => { + await world.run(system, ...input); + // The reader's own run is the newest: its report holds what was deferred to it. + const { found } = await readOf(world, -1); + if (found === null) throw new Error("The next run has no report."); + return found; +}; + +/** Runs a system, then reads its run's report. */ +export const runReported = async (world: World, system: System, ...input: Input>): Promise => + reportOf(world, await world.run(system, ...input)); + +/** + * Follows a world's reports with an observer system it adds: `seen` lists each report as a pass found it changed, and + * `until` resolves with the first one a test passes, once one does. + */ +export const followReports = (world: World) => { + type Seen = { readonly run: number; readonly failures: readonly Report.Failure[] }; + const seen: Seen[] = []; + const waiting: (() => void)[] = []; + const system = System.create({ name: "test/followReports", observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { + seen.push({ run: row[Report.run].number, failures: row[Report.errors].failures }); + for (const wake of waiting.splice(0)) wake(); + }); + world.add(system); + const until = async (test: (report: Seen) => boolean): Promise => { + for (;;) { + const found = seen.find(test); + if (found !== undefined) return found; + await new Promise(resolve => waiting.push(resolve)); + } + }; + return { seen, until, stop: () => world.remove(system) }; +}; + +/** Despawns every report, for tests that compare entity order with a world that has none: later runs spawn them again. */ +export const dropReports = System.create({ name: "test/dropReports", components: { has: [Report.run] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); + +const nothing = System.create({ name: "test/nothing" }, () => {}); + +/** Runs a world until every report it keeps is spawned, so later runs reuse their entities and spawn none. */ +export const fillReports = async (world: World): Promise => { + for (let k = 0; k < retainedRuns; k++) await world.run(nothing); +}; diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts new file mode 100644 index 000000000..5938f6409 --- /dev/null +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { Component, Index, Report, Resource, System, clock, workers } from "../../index.js"; +import { createWorld } from "./create.js"; +import { retainedRuns } from "./reports.js"; +import { reportOf, runOf } from "./reports.test-helper.js"; + +const amount = Symbol("reportsTest/amount"); +const level = Symbol("reportsTest/level"); +declare module "@adobe/ecs" { + interface Components { + [amount]: { type: number }; + } + interface Resources { + [level]: { type: number }; + } +} +Component.register(amount, { type: { type: "number" } }); +Resource.register(level, { type: { type: "number", default: 0 } }); + +const nothing = System.create({ name: "reportsTest/nothing" }, () => {}); +const fail = System.create({ name: "reportsTest/fail" }, () => { throw new Error("run failed"); }); +const raise = System.create({ name: "reportsTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); +const idle = System.create({ name: "reportsTest/idle", observer: true }, () => {}); +const spawn = System.create({ name: "reportsTest/spawn" }, ({ commands }) => { commands.spawn([amount])({ [amount]: 1 }); }); +const despawnAll = System.create({ name: "reportsTest/despawnAll", components: { has: [amount] } }, (row, { commands }) => { + commands.despawn(row.entity); + commands.despawn(row.entity); +}); +const failingObserver = System.create({ name: "reportsTest/failingObserver", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { + if (resources[level] > 0) throw new Error("observer failed"); +}); + +/** What a logger following reports saw, by pass: each changed report's run and failures' messages. */ +const logged: (readonly [number, readonly string[]])[] = []; +const logger = System.create({ name: "reportsTest/logger", observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { + logged.push([row[Report.run].number, row[Report.errors].failures.map(failure => failure.message)]); +}); + +/** The run numbers of every report, in entity order, and of those with errors. */ +let numbers: number[] = []; +let withErrors: number[] = []; +const allReports = Index.create({ name: "reportsTest/allReports", key: [], components: { has: [Report.run] } }); +const erredReports = Index.create({ name: "reportsTest/erredReports", key: [], components: { has: [Report.run, Report.errors] } }); +const listReports = System.create({ name: "reportsTest/listReports", observer: true, lookup: { all: { index: allReports, read: [Report.run] }, erred: { index: erredReports, read: [Report.run] } } }, ({ lookup }) => { + const all = lookup.all(); + numbers = []; + for (let i = 0; i < all.length; i++) numbers.push(all.at(i)[Report.run].number); + const erred = lookup.erred(); + withErrors = []; + for (let i = 0; i < erred.length; i++) withErrors.push(erred.at(i)[Report.run].number); +}); + +const inline = () => createWorld({ [workers]: 0 }); +const sorted = (values: readonly number[]) => [...values].sort((a, b) => a - b); + +describe("reports", () => { + it("number every run in order, one of observer systems alone included, each with a report", async () => { + await using world = inline(); + expect(await world.run(nothing)).toBe(1); + expect(await world.run(raise)).toBe(2); + expect(await world.run(idle)).toBe(3); + expect(await reportOf(world, 2)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + const listed = await world.run(listReports); + expect(sorted(numbers)).toEqual([1, 2, 3, 4, listed]); + expect(withErrors).toEqual([]); + }); + + it("say what a run ran: its systems, on main with no joined thread claiming it, and how long it took", async () => { + await using world = inline(); + const both = await world.run(System.create([nothing, raise])); + const values = await runOf(world, both); + expect({ ...values, elapsed: 0 }).toEqual({ number: both, elapsed: 0, systems: 2, workers: 0, joined: 0, main: true, gpu: false, dropped: 0 }); + expect(values.elapsed).toBeGreaterThanOrEqual(0); + const alone = await runOf(world, await world.run(idle)); + expect({ ...alone, elapsed: 0 }).toEqual({ number: alone.number, elapsed: 0, systems: 0, workers: 0, joined: 0, main: false, gpu: false, dropped: 0 }); + }); + + it("give a failed run errors, which an observer system sees in that run's pass, and a dropping run only its count", async () => { + await using world = inline(); + logged.length = 0; + world.add(logger); + const failed = await world.run(fail); + expect(logged).toEqual([[failed, ["run failed"]]]); + const { failures } = await reportOf(world, failed); + expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "reportsTest/fail", "run failed"]]); + await world.run(spawn); + const dropping = await world.run(despawnAll); + expect(await reportOf(world, dropping)).toEqual({ failures: [], skipped: [], dropped: 1, warnings: [] }); + expect(logged).toHaveLength(1); + await world.run(listReports); + expect(withErrors).toEqual([failed]); + world.remove(logger); + }); + + it("write each report's errors once, an observer pass's failures in the next run's report", async () => { + await using world = inline(); + logged.length = 0; + world.add(failingObserver); + world.add(logger); + const raised = await world.run(raise); + expect(logged).toEqual([]); + expect((await reportOf(world, raised)).failures).toEqual([]); + // The reader's run, the next, holds them, and the logger saw them in its pass. + const { failures } = await reportOf(world, raised + 1); + expect(failures.map(({ name, message }) => [name, message])).toEqual([["reportsTest/failingObserver", "observer failed"]]); + expect(logged).toEqual([[raised + 1, ["observer failed"]]]); + // A failed run's report holds its own failures; its pass's are in the next. + const both = System.create([fail, raise]); + const failed = await world.run(both); + expect((await reportOf(world, failed)).failures.map(({ message }) => message)).toEqual(["run failed"]); + expect((await reportOf(world, failed + 1)).failures.map(({ message }) => message)).toEqual(["observer failed"]); + expect(logged.slice(1)).toEqual([[failed, ["run failed"]], [failed + 1, ["observer failed"]]]); + }); + + it(`keep the last ${retainedRuns} runs' reports, a later run taking the oldest one's entity and dropping its errors`, async () => { + await using world = inline(); + const first = await world.run(fail); + const second = await world.run(fail); + while (await world.run(nothing) < first + retainedRuns - 2); + await world.run(listReports); + expect(numbers).toHaveLength(retainedRuns); + expect(sorted(withErrors)).toEqual([first, second]); + // This run takes the first report's entity, and its errors go. + const next = await world.run(listReports); + expect(numbers).toHaveLength(retainedRuns); + expect(sorted(numbers)[0]).toBe(second); + expect(sorted(numbers).at(-1)).toBe(next); + expect(withErrors).toEqual([second]); + }); + + it("write a reused report's errors in place when its run has errors too, and a logger sees each run's own", async () => { + await using world = inline(); + logged.length = 0; + world.add(logger); + const failWith = System.create({ name: "reportsTest/failWith", input: { type: "object", properties: { k: { type: "integer" } } } }, ({ input }) => { throw new Error(`run ${input.k}`); }); + for (let k = 0; k < retainedRuns; k++) await world.run(failWith, { k }); + logged.length = 0; + const reused = await world.run(failWith, { k: retainedRuns }); + expect(logged).toEqual([[reused, [`run ${retainedRuns}`]]]); + expect((await reportOf(world, reused)).failures.map(({ message }) => message)).toEqual([`run ${retainedRuns}`]); + await world.run(listReports); + expect(numbers).toHaveLength(retainedRuns); + world.remove(logger); + }); + + it("number ticks with runs, apart from the clock's tick, and report a failing tick's systems", async () => { + await using world = inline(); + logged.length = 0; + world.add(logger); + world.add(fail); + let ticked = (): void => {}; + // Its first pass passes its filter; only a tick writes the clock after. + let passes = 0; + const watchTicks = System.create({ name: "reportsTest/watchTicks", observer: true, resources: { changed: [clock] } }, () => { + if (++passes > 1) ticked(); + }); + world.add(watchTicks); + const tick = new Promise(resolve => { ticked = resolve; }); + const run = await world.run(nothing); + await tick; + const next = await world.run(nothing); + expect(next).toBeGreaterThan(run + 1); + const tickRuns = logged.map(([number]) => number).filter(number => number > run && number < next); + expect(tickRuns.length).toBeGreaterThan(0); + expect(logged.find(([number]) => number === tickRuns[0])?.[1]).toEqual(["run failed"]); + }); +}); diff --git a/packages/ecs/src/implementation/world/reports.ts b/packages/ecs/src/implementation/world/reports.ts new file mode 100644 index 000000000..fa733872d --- /dev/null +++ b/packages/ecs/src/implementation/world/reports.ts @@ -0,0 +1,203 @@ +import { Report } from '../../interface/report/report.js'; +import type { Failure, Warning } from '../../interface/report/failure.js'; +import { stampChanged } from './archetype.js'; +import { addCommand, createBuffer, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; +import { writeCell } from './cells.js'; +import type { Views } from '../../interface/registry/layout.js'; +import { pushValues } from './commands.js'; +import { locate, reserve } from './entities.js'; +import type { RunReport } from './failures.js'; +import { idOf, type State } from './state.js'; + +/** How many runs keep their reports: past that, each run's report reuses the oldest one's entity. */ +export const retainedRuns = 256; + +/** What a run reports in `Report.run`. */ +export type RunValues = { + number: number; + elapsed: number; + systems: number; + workers: number; + joined: number; + main: boolean; + gpu: boolean; + dropped: number; +}; + +type Errors = { readonly failures: readonly Failure[]; readonly skipped: readonly string[]; readonly warnings: readonly Warning[] }; + +/** + * A report entity, of the run that last took it. `errors` is what its `Report.errors` holds, or will once commands + * queued for it apply; null without. `own` tells whether those are its run's, not an earlier run's it reuses. + */ +export type Slot = { entity: number; run: number; errors: Errors | null; own: boolean }; + +/** Whether a run has nothing for `Report.errors`. */ +export const isClean = (report: RunReport): boolean => report.failures.length === 0 && report.skipped.length === 0 && report.warnings.length === 0; + +const none: readonly never[] = []; +const noErrors: Errors = { failures: none, skipped: none, warnings: none }; + +/** Fields of `Report.run`, in no particular order: what `createRunWriter` writes. */ +const runFields = ["number", "elapsed", "systems", "workers", "joined", "main", "gpu", "dropped"] as const satisfies readonly (keyof RunValues)[]; + +/** A writer of `RunValues` into a storage of `Report.run`, by the leaf of each field in `layout`. */ +const createRunWriter = (layout: State["layouts"][number]) => { + if (layout.kind !== "linear") throw new Error("Report.run isn't a struct."); + const leafOf = (field: string) => { + const leaf = layout.leaves.find(each => each.path[0] === field); + if (leaf === undefined) throw new Error(`Report.run has no ${field}.`); + return leaf; + }; + const [number, elapsed, systems, workers, joined, main, gpu, dropped] = runFields.map(leafOf); + return (views: Views, row: number, values: RunValues): void => { + views[number.view][row * number.unitStride + number.unitOffset] = values.number; + views[elapsed.view][row * elapsed.unitStride + elapsed.unitOffset] = values.elapsed; + views[systems.view][row * systems.unitStride + systems.unitOffset] = values.systems; + views[workers.view][row * workers.unitStride + workers.unitOffset] = values.workers; + views[joined.view][row * joined.unitStride + joined.unitOffset] = values.joined; + views[main.view][row * main.unitStride + main.unitOffset] = values.main ? 1 : 0; + views[gpu.view][row * gpu.unitStride + gpu.unitOffset] = values.gpu ? 1 : 0; + views[dropped.view][row * dropped.unitStride + dropped.unitOffset] = values.dropped; + }; +}; + +/** + * The world's reports: an entity for each of the last `retainedRuns` runs and ticks. Each run takes one as it starts: + * a new one, spawned by its own sync point, until that many are, then the oldest. Once its sync point is done, its + * `Report.run` is written in place, stamped so `changed` filters see it. `Report.errors` is written in place too when + * the entity holds it already, so a reused entity changes archetype only when its run's errors come or go; adding or + * removing it joins the run's sync point when known before it, else applies with `applyAlone`. + */ +export const createReports = (state: State, applyAlone: (buffer: CommandBuffer) => Promise | null) => { + const runId = idOf(state, Report.run); + const errorsId = idOf(state, Report.errors); + state.meta.add(runId).add(errorsId); + const slots: Slot[] = []; + /** The slot the next run takes once every one is spawned. */ + let oldest = 0; + /** The slot the last run took. */ + let last: Slot | null = null; + + /** The storage and row of a slot's component `id`, while its entity holds it. */ + const cellOf = (slot: Slot, id: number) => { + const index = locate(state.entities, slot.entity); + if (index < 0) return null; + const storage = state.archetypes[state.entities.archetype[index]]?.storageById[id]; + return storage === undefined ? null : { storage, row: state.entities.row[index] }; + }; + + /** Writes `Report.run` field by field through its layout's leaves, without the general writer's walk. */ + const writeRun = createRunWriter(state.layouts[runId]); + + /** Writes `errors` into a slot: in place when its entity holds them, stamped; else by an add, queued or alone. */ + const putErrors = (slot: Slot, errors: Errors, queue: boolean): Promise | null => { + const pending = slot.errors !== null && cellOf(slot, errorsId) === null; + slot.errors = errors; + slot.own = true; + const cell = cellOf(slot, errorsId); + if (cell !== null) { + writeCell(cell.storage, cell.row, errors); + stampChanged(cell.storage, cell.row, ++state.tick); + return null; + } + const buffer = createBuffer(); + pushValues(state, buffer, addCommand, slot.entity, { [Report.errors]: errors }); + if (queue || pending) { + state.queued.push({ buffer, guarded: false }); + return null; + } + return applyAlone(buffer); + }; + + /** + * Takes run `run`'s slot: a new entity, spawned by commands queued for the next sync point, until every one is, + * then the oldest, or a new one in its place when a system despawned it. + */ + const take = (run: number): Slot => { + let slot: Slot; + if (slots.length < retainedRuns) { + slot = { entity: -1, run, errors: null, own: false }; + slots.push(slot); + } + else { + slot = slots[oldest]; + oldest = (oldest + 1) % retainedRuns; + slot.run = run; + slot.own = false; + if (locate(state.entities, slot.entity) >= 0) return last = slot; + slot.errors = null; + } + slot.entity = reserve(state.entities); + const buffer = createBuffer(); + pushValues(state, buffer, spawnCommand, slot.entity, { + [Report.run]: { number: run, elapsed: 0, systems: 0, workers: 0, joined: 0, main: false, gpu: false, dropped: 0 } satisfies RunValues, + }); + state.queued.push({ buffer, guarded: false }); + return last = slot; + }; + + return { + /** The slot the last run took. */ + lastSlot: (): Slot => { + if (last === null) throw new Error("No run took a report yet."); + return last; + }, + /** Takes the report of run `run`, which is about to run: what that takes joins the run's own sync point. */ + begin: take, + /** Takes run `run`'s report between runs: what that takes applies as a sync point of its own. */ + beginAlone: (run: number): Slot | Promise => { + const queued = state.queued.length; + const slot = take(run); + if (state.queued.length === queued) return slot; + const applying = applyAlone(state.queued.splice(queued, 1)[0].buffer); + return applying === null ? slot : applying.then(() => slot); + }, + /** + * Puts, for the run that took the last slot, `Report.errors` for what went wrong before its sync point: + * failures, skipped systems and the first `warningCount` warnings, copied, since the run's lists grow. + */ + beforeSync: (failures: readonly Failure[] | null, skipped: readonly string[] | null, warnings: readonly Warning[], warningCount: number): void => { + if (last === null || (failures === null && skipped === null && warningCount === 0)) return; + const errors = { failures: failures === null ? none : failures.slice(), skipped: skipped === null ? none : skipped.slice(), warnings: warningCount === 0 ? none : warnings.slice(0, warningCount) }; + void putErrors(last, errors, true); + }, + /** + * Writes run `run`'s report into its slot once its sync point is done: `Report.run`, then its errors unless + * they were put before it, or clears those an earlier run left. A report's errors are written once: failures + * found after go to `defer`, for the next report. While tails run on joined threads, a removal waits for the + * next sync point, the errors emptied meanwhile. + */ + finish: (slot: Slot, values: RunValues, report: RunReport, tailsRun: boolean, defer: (failures: readonly Failure[]) => void): Promise | null => { + if (slot.run !== values.number) return null; + const cell = cellOf(slot, runId); + if (cell === null) return null; + writeRun(cell.storage.views, cell.row, values); + stampChanged(cell.storage, cell.row, ++state.tick); + if (!isClean(report)) { + const given = slot.own ? slot.errors : null; + if (given === null) return putErrors(slot, { failures: report.failures, skipped: report.skipped, warnings: report.warnings }, tailsRun); + // Its errors are written once: failures found since go to the next report. + if (report.failures.length > given.failures.length) defer(report.failures.slice(given.failures.length)); + return null; + } + if (slot.errors === null || slot.own) return null; + const held = cellOf(slot, errorsId); + if (held !== null) { + writeCell(held.storage, held.row, noErrors); + stampChanged(held.storage, held.row, ++state.tick); + } + slot.errors = null; + const buffer = createBuffer(); + pushCommand(buffer, removeCommand, slot.entity); + pushComponent(buffer, errorsId, undefined); + if (tailsRun) { + state.queued.push({ buffer, guarded: false }); + return null; + } + return applyAlone(buffer); + }, + }; +}; + +export type Reports = ReturnType; diff --git a/packages/ecs/src/implementation/world/resources.ts b/packages/ecs/src/implementation/world/resources.ts new file mode 100644 index 000000000..574b9562b --- /dev/null +++ b/packages/ecs/src/implementation/world/resources.ts @@ -0,0 +1,67 @@ +import type { Storage } from './archetype.js'; +import { indexOf, reserve } from './entities.js'; +import { defaultOf, idOf, type Key, type State } from './state.js'; +import { synchronizeAlone } from './synchronize.js'; +import { addCommand, createBuffer, spawnCommand } from './buffer.js'; +import { pushValues } from './commands.js'; +import { labelOf } from '../../interface/registry/index.js'; + +/** Resources are components on one entity, so they share storage, ticks and sync with every other component. */ +export type ResourceCell = { readonly storage: Storage; readonly row: number }; + +/** A resource's value before anything writes it: the one given at the world's creation, or its default. */ +export const initialOf = (state: State, key: Key): unknown => state.initial.has(key) ? state.initial.get(key) : defaultOf(key); + +/** + * Adds any missing resources with their initial values, as their own sync point, between runs; commands that are + * pending stay for the next run. + */ +export const ensureResources = (state: State, keys: Iterable): void => { + const index = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + const archetype = index < 0 ? undefined : state.archetypes[state.entities.archetype[index]]; + const values: { [key: Key]: unknown } = {}; + for (const key of keys) { + const id = idOf(state, key); + // A joined thread's object starts there; the world holds none of its values. + if (archetype?.storageOf.has(id) !== true) values[key] = state.remote[id] === undefined ? initialOf(state, key) : state.starts[id]; + } + if (Reflect.ownKeys(values).length === 0) return; + const buffer = createBuffer(); + if (index < 0) { + state.resourceEntity = reserve(state.entities); + pushValues(state, buffer, spawnCommand, state.resourceEntity, values); + } + else { + pushValues(state, buffer, addCommand, state.resourceEntity, values); + } + synchronizeAlone(state, buffer); +}; + +/** Sets a resource as its own sync point, between runs, so systems see the change as they see any write. */ +export const setResource = (state: State, key: Key, value: unknown): void => { + if (findResource(state, key) === undefined) { + // No registered system uses it yet; it starts at this value when one does. + state.initial.set(key, value); + return; + } + const buffer = createBuffer(); + pushValues(state, buffer, addCommand, state.resourceEntity, { [key]: value }); + synchronizeAlone(state, buffer); +}; + +/** Where a resource lives now, or `undefined` if no registered system uses it. Valid until the next sync point. */ +export const findResource = (state: State, key: Key): ResourceCell | undefined => { + if (state.resourceEntity < 0) return undefined; + const index = indexOf(state.resourceEntity); + const storage = state.archetypes[state.entities.archetype[index]]?.storageOf.get(idOf(state, key)); + return storage === undefined ? undefined : { storage, row: state.entities.row[index] }; +}; + +/** Where a resource lives now. Valid until the next sync point. */ +export const resourceCell = (state: State, key: Key): ResourceCell => { + const index = indexOf(state.resourceEntity); + const archetype = state.archetypes[state.entities.archetype[index]]; + const storage = archetype?.storageOf.get(idOf(state, key)); + if (storage === undefined) throw new Error(`Resource "${labelOf(key)}" isn't in this world.`); + return { storage, row: state.entities.row[index] }; +}; diff --git a/packages/ecs/src/implementation/world/rows.test.ts b/packages/ecs/src/implementation/world/rows.test.ts new file mode 100644 index 000000000..17307c875 --- /dev/null +++ b/packages/ecs/src/implementation/world/rows.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +type Vec3 = readonly [number, number, number]; +const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; +const position = Symbol("rowsTest/position"); +const count = Symbol("rowsTest/count"); +const flag = Symbol("rowsTest/flag"); +const tag = Symbol("rowsTest/tag"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number, number] }; + [count]: { type: number }; + [flag]: { type: boolean }; + [tag]: { type: true }; + } +} + +Component.register(position, { type: vec3 }); +Component.register(count, { type: { type: "integer", minimum: 0, maximum: 1000 } }); +Component.register(flag, { type: { type: "boolean" } }); +Component.register(tag, { type: { const: true } }); + +const seed = System.create({ name: "rowsTest/seed" }, ({ commands }) => { + for (let i = 0; i < 4; i++) commands.spawn(i < 2 ? [position, count, flag, tag] : [position, count, flag])({ [position]: [i, i + 0.5, -i], [count]: i, [flag]: i % 2 === 0 }); +}); + +describe("accessor rows", () => { + it("are shared by systems with the same reads and writes, rebound per system and archetype", async () => { + const seen: { entity: Entity; position: Vec3; count: number; flag: boolean }[] = []; + const kept: Vec3[] = []; + // Both run over two archetypes (with and without the tag), with a system using a different row between them. + const first = System.create({ name: "rowsTest/first", components: { read: [position, flag], write: [count] } }, row => { + const value = row[position]; + kept.push(value); + seen.push({ entity: row.entity, position: value, count: row[count], flag: row[flag] }); + row[count] = row[count] + 10; + }); + const other = System.create({ name: "rowsTest/other", components: { write: [position] }, after: [first] }, row => { + const [x, y, z] = row[position]; + row[position] = [x + 100, y, z]; + }); + const second = System.create({ name: "rowsTest/second", components: { read: [position, flag], write: [count] }, after: [other] }, row => { + seen.push({ entity: row.entity, position: row[position], count: row[count], flag: row[flag] }); + row[count] = row[count] + 1; + }); + const world = createWorld({ [workers]: 0 }); + await world.run(seed); + await world.run(first); + await world.run(other); + await world.run(second); + const entities = await select(world, { required: [count] }); + const values = await readEach(world, entities, count); + const byEntity = new Map(entities.map((entity, i) => [entity, values[i] ?? -1])); + expect([...byEntity.values()].sort()).toEqual([11, 12, 13, 14]); + expect(seen).toHaveLength(8); + for (const { entity, position: value, count: initial, flag: even } of seen.slice(0, 4)) { + expect(value).toEqual([initial, initial + 0.5, -initial]); + expect(even).toBe(initial % 2 === 0); + expect(byEntity.get(entity)).toBe(initial + 11); + } + for (const { entity, position: value, count: written } of seen.slice(4)) { + const initial = written - 10; + expect(value).toEqual([initial + 100, initial + 0.5, -initial]); + expect(byEntity.get(entity)).toBe(written + 1); + } + // Read values are copies: later writes don't change a value a body kept. + expect(kept.map(value => value[0]).sort()).toEqual([0, 1, 2, 3]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts new file mode 100644 index 000000000..5f859626f --- /dev/null +++ b/packages/ecs/src/implementation/world/rows.ts @@ -0,0 +1,515 @@ +import type { Entity } from '../../interface/entity/entity.js'; +import type { Declaration } from '../../interface/system/declaration.js'; +import type { SystemLookup } from '../../interface/system/access.js'; +import type { Commands } from '../../interface/system/commands.js'; +import type { Layout, TypedArray } from '../../interface/registry/layout.js'; +import { stampChanged, writeF32Lanes, writeGeneral, writeOf, type Archetype, type Storage } from './archetype.js'; +import { readCell, writeCell } from './cells.js'; +import { readString, writeString } from './strings.js'; +import { indexOf, locate } from './entities.js'; +import { ownTag } from './move-log.js'; +import { resourceCell } from './resources.js'; +import { idOf, type Key, type State } from './state.js'; +import { combine, contributed, identityOf, type ReductionTarget } from './reductions.js'; +import { createIndexLookup, type Since } from './index-lookups.js'; +import type { WorldIndex } from './indexes.js'; + +/** + * What an accessor row reads and writes: a storage, or a joined thread's view of one. A joined thread's own objects + * have `tags`, the world's tags of their rows, which a write marks as the thread's own value. + */ +export type RowStorage = Pick; + +/** What a row binds to: an archetype, or a joined thread's view of one. */ +export type RowArchetype = { readonly id: number; readonly entities: Float64Array; readonly storageById: readonly (RowStorage | undefined)[] }; + +/** + * What rows need of their world: layouts by component id, the rows made so far, the running system's tick, and on a + * joined thread, that its strings are read out of their records, since only the world's thread holds their values. + */ +export type RowHost = { readonly layouts: readonly Layout[]; readonly rows: Map; readonly tick: number; readonly records?: true }; + +/** One declared component's storage in the archetype a row is bound to, and the typed array its scalar leaves are read through. */ +type Slot = { storage: RowStorage; view: TypedArray }; + +/** + * An accessor row for the declared components of a system. `bind` points it at an archetype's storages and `index` + * selects the row. Reads copy linear values out; writes replace the whole value and stamp `changed` with the running + * system's tick. + */ +export type Row = { + readonly value: object; + index: number; + entities: Float64Array; + /** Points the row at another archetype's storages. Views are replaced only at sync points, so a binding lasts a pass. */ + readonly bind: (archetype: RowArchetype) => void; +}; + +/** Chosen once per component, so a read doesn't dispatch on the layout; tuples of f32 lanes are read as array literals. */ +const getterOf = (host: RowHost, layout: Layout, slot: Slot, row: Row): () => unknown => { + if (layout.kind === "constant") { + const { value } = layout; + return () => value; + } + if (layout.kind === "object" || (layout.kind === "string" && host.records !== true)) return () => slot.storage.values[row.index]; + if (layout.kind === "string") return () => readString(slot.storage, row.index); + const { write, unitStride, unitOffset, lanes } = writeOf(layout); + if (write === writeF32Lanes && lanes === 2) return () => { + const { view } = slot; + const base = row.index * unitStride + unitOffset; + return [view[base], view[base + 1]]; + }; + if (write === writeF32Lanes && lanes === 3) return () => { + const { view } = slot; + const base = row.index * unitStride + unitOffset; + return [view[base], view[base + 1], view[base + 2]]; + }; + if (write === writeF32Lanes && lanes === 4) return () => { + const { view } = slot; + const base = row.index * unitStride + unitOffset; + return [view[base], view[base + 1], view[base + 2], view[base + 3]]; + }; + if (write !== writeGeneral && write !== writeF32Lanes) return () => slot.view[row.index * unitStride + unitOffset]; + return () => layout.read(slot.storage.views, row.index); +}; + +const setterOf = (host: RowHost, layout: Layout, slot: Slot, row: Row): (value: unknown) => void => { + if (layout.kind === "constant") return () => stampChanged(slot.storage, row.index, host.tick); + if (layout.kind === "object") return value => { + const { storage } = slot; + const { index } = row; + storage.values[index] = value; + // A joined thread's own object: the world's tag says its value is the thread's. + if (storage.tags.length !== 0) storage.tags[index] = ownTag; + stampChanged(storage, index, host.tick); + }; + if (layout.kind === "string") return value => { + const { storage } = slot; + writeString(storage, row.index, value); + stampChanged(storage, row.index, host.tick); + }; + const { write, unitStride, unitOffset, lanes } = writeOf(layout); + // Array values of f32 lanes and scalars are stored directly, converting as `layout.write` does. + if (write === writeF32Lanes) return value => { + const { storage, view } = slot; + const { index } = row; + if (Array.isArray(value)) { + const base = index * unitStride + unitOffset; + for (let i = 0; i < lanes; i++) view[base + i] = Number(value[i]); + } + else layout.write(storage.views, index, value); + stampChanged(storage, index, host.tick); + }; + if (write !== writeGeneral) return value => { + const { index } = row; + slot.view[index * unitStride + unitOffset] = Number(value); + stampChanged(slot.storage, index, host.tick); + }; + return value => { + const { storage } = slot; + layout.write(storage.views, row.index, value); + stampChanged(storage, row.index, host.tick); + }; +}; + +/** The view a slot's scalar leaves are read through: their own for a scalar or f32 lanes, unused otherwise. */ +const viewOf = (storage: RowStorage): TypedArray => { + const { layout } = storage; + return layout.kind === "linear" && storage.write !== writeGeneral ? storage.views[layout.leaves[0].view] : storage.views.f32; +}; + +const slotOf = (archetype: RowArchetype, id: number): Slot => { + const storage = archetype.storageById[id]; + // A system matches only archetypes with every component it reads or writes. + if (storage === undefined) throw new Error(`Archetype ${archetype.id} lacks component ${id}.`); + return { storage, view: viewOf(storage) }; +}; + +export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly Key[], ids: readonly number[], writes: readonly Key[]): Row => { + const slots = ids.map(id => slotOf(archetype, id)); + // Its own prototype gives the row its own root map. Rows built from `{}` share one, and defining an accessor with a + // different getter than an existing transition's makes V8 switch the object to slow dictionary properties. + const value: object = Object.create({}); + const row: Row = { + value, + index: 0, + entities: archetype.entities, + bind: next => { + row.entities = next.entities; + for (let i = 0; i < ids.length; i++) { + const { storage, view } = slotOf(next, ids[i]); + slots[i].storage = storage; + slots[i].view = view; + } + }, + }; + Object.defineProperty(value, "entity", { get: () => row.entities[row.index], enumerable: true }); + keys.forEach((key, i) => { + const layout = host.layouts[ids[i]]; + Object.defineProperty(value, key, { + get: getterOf(host, layout, slots[i], row), + set: writes.includes(key) ? setterOf(host, layout, slots[i], row) : undefined, + enumerable: true, + }); + }); + return row; +}; + +/** The row a shared-class row's value reads through, as its accessors' `this`. */ +const rowOfValue = Symbol("row"); + +/** A row of shared class: its slots are its own, for the accessors every row of its shape shares. */ +type SharedRow = Row & { readonly slots: Slot[] }; + +/** A shared-class row's value, as its accessors see it. */ +type SharedValue = { readonly [rowOfValue]: SharedRow }; + +/** As `getterOf`, but one function for every row of a shape, finding its row and slot through the value. */ +const sharedGetterOf = (host: RowHost, layout: Layout, i: number): (this: SharedValue) => unknown => { + if (layout.kind === "constant") { + const { value } = layout; + return () => value; + } + if (layout.kind === "object" || (layout.kind === "string" && host.records !== true)) return function (this: SharedValue): unknown { + const row = this[rowOfValue]; + return row.slots[i].storage.values[row.index]; + }; + if (layout.kind === "string") return function (this: SharedValue): unknown { + const row = this[rowOfValue]; + return readString(row.slots[i].storage, row.index); + }; + const { write, unitStride, unitOffset, lanes } = writeOf(layout); + if (write === writeF32Lanes && lanes === 2) return function (this: SharedValue): unknown { + const row = this[rowOfValue]; + const { view } = row.slots[i]; + const base = row.index * unitStride + unitOffset; + return [view[base], view[base + 1]]; + }; + if (write === writeF32Lanes && lanes === 3) return function (this: SharedValue): unknown { + const row = this[rowOfValue]; + const { view } = row.slots[i]; + const base = row.index * unitStride + unitOffset; + return [view[base], view[base + 1], view[base + 2]]; + }; + if (write === writeF32Lanes && lanes === 4) return function (this: SharedValue): unknown { + const row = this[rowOfValue]; + const { view } = row.slots[i]; + const base = row.index * unitStride + unitOffset; + return [view[base], view[base + 1], view[base + 2], view[base + 3]]; + }; + if (write !== writeGeneral && write !== writeF32Lanes) return function (this: SharedValue): unknown { + const row = this[rowOfValue]; + return row.slots[i].view[row.index * unitStride + unitOffset]; + }; + return function (this: SharedValue): unknown { + const row = this[rowOfValue]; + return layout.read(row.slots[i].storage.views, row.index); + }; +}; + +const sharedEntity = function (this: SharedValue): number { + const row = this[rowOfValue]; + return row.entities[row.index]; +}; + +/** + * Makes read-only accessor rows for `keys` whose values share one hidden class: every value's own accessors are the + * same functions, finding the row through the value. A body reading many rows at once, as a list's, sees one map. + */ +export const sharedRows = (host: RowHost, keys: readonly Key[], ids: readonly number[]): (archetype: RowArchetype) => Row => { + const prototype = {}; + const getters = keys.map((_, i) => sharedGetterOf(host, host.layouts[ids[i]], i)); + return archetype => { + const value: object = Object.create(prototype); + const row: SharedRow = { + value, + index: 0, + entities: archetype.entities, + slots: ids.map(id => slotOf(archetype, id)), + bind: next => { + row.entities = next.entities; + for (let i = 0; i < ids.length; i++) { + const { storage, view } = slotOf(next, ids[i]); + row.slots[i].storage = storage; + row.slots[i].view = view; + } + }, + }; + Object.defineProperty(value, rowOfValue, { value: row }); + Object.defineProperty(value, "entity", { get: sharedEntity, enumerable: true }); + for (let i = 0; i < keys.length; i++) Object.defineProperty(value, keys[i], { get: getters[i], enumerable: true }); + return row; + }; +}; + +/** + * Binds a read-only accessor row of its own, for the parents an `up` system reads, to an archetype. It isn't shared, + * so binding it never moves the row a system runs over, even when both read the same components. + */ +export const parentBinder = (state: State, reads: readonly Key[]): (archetype: Archetype) => Row => { + const ids = reads.map(key => idOf(state, key)); + let row: Row | null = null; + return archetype => { + if (row === null) row = createRow(state, archetype, reads, ids, []); + else row.bind(archetype); + return row; + }; +}; + +/** + * Binds the accessor row for `reads` and `writes` to an archetype. The row is shared by every system of its host that + * declares the same ones, so a body sees one row shape whichever system or archetype it runs over. A host's systems + * run one at a time, so a binding lasts for one system's pass over one archetype. `idOfKey` gives each key's component. + */ +export const rowBinderOf = (host: RowHost, idOfKey: (key: Key) => number, reads: readonly Key[], writes: readonly Key[]): (archetype: A) => Row => { + const keys = [...new Set([...reads, ...writes])]; + const ids = keys.map(idOfKey); + const signature = keys.map((key, i) => writes.includes(key) ? `${ids[i]}w` : `${ids[i]}`).join(","); + return archetype => { + let row = host.rows.get(signature); + if (row === undefined) { + row = createRow(host, archetype, keys, ids, writes); + host.rows.set(signature, row); + } + else row.bind(archetype); + return row; + }; +}; + +/** The accessor row binder for main-thread systems of a world. */ +export const rowBinder = (state: State, reads: readonly Key[], writes: readonly Key[]): (archetype: Archetype) => Row => + rowBinderOf(state, key => idOf(state, key), reads, writes); + +/** + * A lookup's matcher, with its component ids resolved once. `find` returns the row of a live entity that matches, or -1, + * leaving its archetype in `archetype`. + */ +const createMatcher = (state: State, lookup: SystemLookup) => { + const ids = (keys: readonly Key[] | undefined): number[] => (keys ?? []).map(key => idOf(state, key)); + const read = ids(lookup.read); + const has = ids("has" in lookup ? lookup.has : undefined); + const not = ids("not" in lookup ? lookup.not : undefined); + const matcher = { + read, + archetype: null as Archetype | null, + find: (entity: Entity): number => { + const index = locate(state.entities, entity); + if (index < 0) return -1; + const archetype = state.archetypes[state.entities.archetype[index]]; + if (archetype.resource) return -1; + const { storageById } = archetype; + for (let i = 0; i < read.length; i++) if (storageById[read[i]] === undefined) return -1; + for (let i = 0; i < has.length; i++) if (storageById[has[i]] === undefined) return -1; + for (let i = 0; i < not.length; i++) if (storageById[not[i]] !== undefined) return -1; + const row = state.entities.row[index]; + // While finalizers run, rows spawned in the same sync point aren't alive yet. + if (state.born !== null && row >= (state.born.get(archetype.id) ?? Infinity)) return -1; + matcher.archetype = archetype; + return row; + }, + }; + return matcher; +}; + +const readAt = (archetype: Archetype, row: number, id: number): unknown => { + const storage = archetype.storageById[id]; + return storage === undefined ? undefined : readCell(storage, row); +}; + +/** What a lookup by entity reading nothing gives every entity it finds. */ +export const emptyRow: object = {}; + +/** A lookup by entity's row with `changed`: its `read` values by component, and `changed`. */ +export type ChangedRow = { [key: Key]: unknown; changed: boolean }; + +/** A row for a lookup's `read` and `changed`, every field present from the start, so its writes keep one hidden class. */ +export const changedRowOf = (keys: readonly Key[]): ChangedRow => { + const row: ChangedRow = { changed: false }; + for (let i = 0; i < keys.length; i++) row[keys[i]] = undefined; + return row; +}; + +/** + * Whether a component of `ids` may have changed in an archetype's row after `since`: its row's or its column's tick is + * newer, or it keeps no ticks, so it's unknown. + */ +const changedAt = (archetype: Archetype, row: number, ids: readonly number[], since: number): boolean => { + if (since < 0) return true; + const { storageById } = archetype; + for (let i = 0; i < ids.length; i++) { + const storage = storageById[ids[i]]; + if (storage === undefined || !storage.ticked || storage.changed[row] > since || storage.columnChanged > since) return true; + } + return false; +}; + +/** + * A lookup returning `read` values keyed by component, for main-thread systems: a new object each call, the shared + * empty one for a lookup reading nothing, and with `changed` one row reused, its `changed` as of `since`. + */ +export const createObjectLookup = (state: State, lookup: SystemLookup, since: Since) => { + const matcher = createMatcher(state, lookup); + const keys = lookup.read ?? []; + const watched = "index" in lookup ? undefined : lookup.changed; + if (watched !== undefined) { + const ids = watched.map(key => idOf(state, key)); + const found = changedRowOf(keys); + return (entity: Entity): object | null => { + const row = matcher.find(entity); + const { archetype } = matcher; + if (row < 0 || archetype === null) return null; + for (let i = 0; i < keys.length; i++) found[keys[i]] = readAt(archetype, row, matcher.read[i]); + found.changed = changedAt(archetype, row, ids, since.value); + return found; + }; + } + if (keys.length === 0) return (entity: Entity): object | null => matcher.find(entity) < 0 ? null : emptyRow; + return (entity: Entity): object | null => { + const row = matcher.find(entity); + const { archetype } = matcher; + if (row < 0 || archetype === null) return null; + const values: { [key: Key]: unknown } = {}; + for (let i = 0; i < keys.length; i++) values[keys[i]] = readAt(archetype, row, matcher.read[i]); + return values; + }; +}; + +/** + * A lookup returning `read` values in declared order, for kernels: a new array each call, and with `changed` one array + * reused, whether a component it watches may have changed after `since` last. + */ +export const createArrayLookup = (state: State, lookup: SystemLookup, since: Since) => { + const watched = "index" in lookup ? undefined : lookup.changed; + return watched === undefined ? createPlainArrayLookup(state, lookup) : createChangedArrayLookup(state, lookup, watched.map(key => idOf(state, key)), since); +}; + +const createChangedArrayLookup = (state: State, lookup: SystemLookup, ids: readonly number[], since: Since) => { + const matcher = createMatcher(state, lookup); + const { read } = matcher; + const values = new Array(read.length + 1); + return (entity: Entity): readonly unknown[] | null => { + const row = matcher.find(entity); + const { archetype } = matcher; + if (row < 0 || archetype === null) return null; + for (let i = 0; i < read.length; i++) values[i] = readAt(archetype, row, read[i]); + values[read.length] = changedAt(archetype, row, ids, since.value); + return values; + }; +}; + +const createPlainArrayLookup = (state: State, lookup: SystemLookup) => { + const matcher = createMatcher(state, lookup); + const { read } = matcher; + return (entity: Entity): readonly unknown[] | null => { + const row = matcher.find(entity); + const { archetype } = matcher; + if (row < 0 || archetype === null) return null; + const values = new Array(read.length); + for (let i = 0; i < read.length; i++) values[i] = readAt(archetype, row, read[i]); + return values; + }; +}; + +/** + * A body's context: its resources, lookups, reductions, input and commands, each under its own key and present only + * when declared. `bind` resolves the resources' cells for a run; `reduced[t]` holds the leaves of the fold into its + * `t`th reduction target so far this run. + */ +export type Context = { readonly value: { input?: unknown; up?: unknown }; readonly bind: () => void; readonly reduced: readonly Float64Array[] }; + +/** + * What a context holds beyond the declaration's resources and lookups, and for lookups through indexes, the index of + * each by name and the `lastRun` their lists compare with. + */ +type Extras = { + readonly targets?: readonly ReductionTarget[]; + readonly commands?: Commands; + readonly input?: boolean; + readonly indexes?: ReadonlyMap; + readonly since?: Since; +}; + +/** The `lastRun` of a context without one, as a finalizer's: every list counts as changed. */ +const never: Since = { value: -1 }; + +/** An object with its own prototype, so it gets its own root map, as rows do. */ +const own = (): object => Object.create({}); + +/** Resource cells are resolved once per run: they move only at sync points. An unresolved resource throws on access. */ +export const createContext = (state: State, declaration: Pick, { targets = [], commands, input = false, indexes, since = never }: Extras = {}): Context => { + const value: { input?: unknown; up?: unknown } = own(); + const { lookup, resources } = declaration; + const names = Object.keys(lookup ?? {}); + if (names.length > 0) { + const lookups = own(); + for (const name of names) { + const declared = lookup?.[name] ?? {}; + const index = indexes?.get(name); + const found = index === undefined || !("index" in declared) ? createObjectLookup(state, declared, since) : createIndexLookup(state, index, declared, since); + Object.defineProperty(lookups, name, { value: found, enumerable: true }); + } + Object.defineProperty(value, "lookup", { value: lookups, enumerable: true }); + } + const writes = new Set(resources?.write); + const keys = [...new Set([...resources?.read ?? [], ...resources?.is ?? [], ...writes])]; + const ids = keys.map(key => idOf(state, key)); + /** By key, its storage in the resource entity's archetype as of `bind`, and the entity's row there. */ + const storages: (Storage | undefined)[] = keys.map(() => undefined); + let row = 0; + if (keys.length > 0) { + const values = own(); + keys.forEach((key, i) => { + Object.defineProperty(values, key, { + get: () => { + const storage = storages[i]; + if (storage !== undefined) return readCell(storage, row); + // Throws, as for a resource not in this world. + const cell = resourceCell(state, key); + return readCell(cell.storage, cell.row); + }, + set: writes.has(key) ? (next: unknown) => { + let storage = storages[i]; + let at = row; + if (storage === undefined) { + const cell = resourceCell(state, key); + storage = cell.storage; + at = cell.row; + } + writeCell(storage, at, next); + stampChanged(storage, at, state.tick); + } : undefined, + enumerable: true, + }); + }); + Object.defineProperty(value, "resources", { value: values, enumerable: true }); + } + // Each resource it reduces is a function folding a value into the run's accumulators, leaf by leaf. + const reduced = targets.map(target => new Float64Array(target.leaves.length)); + if (targets.length > 0) { + const reducers = own(); + targets.forEach((target, t) => { + const accumulators = reduced[t]; + const { operator, leaves } = target; + Object.defineProperty(reducers, target.key, { + value: (contribution: unknown): void => { + if (accumulators === undefined) return; + for (let i = 0; i < leaves.length; i++) accumulators[i] = combine(operator, accumulators[i] ?? NaN, contributed(contribution, leaves[i]?.path ?? [])); + }, + enumerable: true, + }); + }); + Object.defineProperty(value, "reduce", { value: reducers, enumerable: true }); + } + if (input) value.input = undefined; + if (commands !== undefined) Object.defineProperty(value, "commands", { value: commands, enumerable: true }); + const bind = (): void => { + if (keys.length > 0) { + const index = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + const archetype = index < 0 ? undefined : state.archetypes[state.entities.archetype[index]]; + row = index < 0 ? 0 : state.entities.row[index]; + for (let i = 0; i < keys.length; i++) storages[i] = archetype?.storageById[ids[i]]; + } + for (let t = 0; t < targets.length; t++) reduced[t]?.fill(identityOf(targets[t].operator)); + }; + return { value, bind, reduced }; +}; diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts new file mode 100644 index 000000000..769291cd8 --- /dev/null +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Rule } from "../../interface/rule/rule.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { count, read } from "./reads.test-helper.js"; +import { workers } from "../../interface/resources/workers.js"; +import { identify } from "../../interface/registry/ids.test-helper.js"; +import { labelOfItem, programRules } from "../../interface/registry/index.js"; + +const source = Symbol("rulesTest/source"); +const hidden = Symbol("rulesTest/hidden"); +const sprite = Symbol("rulesTest/sprite"); +const slot = Symbol("rulesTest/slot"); +const visible = Symbol("rulesTest/visible"); +const ready = Symbol("rulesTest/ready"); +const glow = Symbol("rulesTest/glow"); +const owned = Symbol("rulesTest/owned"); +const follows = Symbol("rulesTest/follows"); +const mark = Symbol("rulesTest/mark"); +const left = Symbol("rulesTest/left"); +const right = Symbol("rulesTest/right"); +const late = Symbol("rulesTest/late"); +const tagged = Symbol("rulesTest/tagged"); +const tag = Symbol("rulesTest/tag"); + +declare module "@adobe/ecs" { + export interface Components { + [source]: { type: number }; + [hidden]: { type: true }; + [sprite]: { type: true }; + [slot]: { type: number; derived: true }; + [visible]: { type: true; derived: true }; + [ready]: { type: number; within: typeof slot }; + [glow]: { type: true; within: typeof ready }; + [owned]: { type: Entity; reference: "owner" }; + [follows]: { type: Entity; reference: "weak" }; + [mark]: { type: number }; + [left]: { type: true; within: typeof right }; + [right]: { type: true; within: typeof left }; + [late]: { type: true; derived: true }; + [tagged]: { type: true }; + [tag]: { type: true; derived: true }; + } +} + +Component.register(source, { type: { type: "number" } }); +Component.register(hidden, { type: { const: true } }); +Component.register(sprite, { type: { const: true } }); +Component.register(slot, { type: { type: "number", default: 7 }, derived: true }); +Component.register(visible, { type: { const: true }, derived: true }); +Component.register(ready, { type: { type: "number" }, within: slot }); +Component.register(glow, { type: { const: true }, within: ready }); +Component.register(owned, { type: Entity.schema, reference: "owner" }); +Component.register(follows, { type: Entity.schema, reference: "weak" }); +Component.register(mark, { type: { type: "number" } }); +Component.register(left, { type: { const: true }, within: right }); +Component.register(late, { type: { const: true }, derived: true }); +Component.register(tagged, { type: { const: true } }); +Component.register(tag, { type: { const: true }, derived: true }); + +Rule.create(identify({ name: "rulesTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); +Rule.create({ name: "rulesTest/visible", components: { has: [slot] }, derive: [visible] }); +Rule.create({ name: "rulesTest/sprites", components: { has: [sprite] }, derive: [visible] }); +Rule.create({ name: "rulesTest/tag", components: { has: [tagged] }, derive: [tag] }); + +/** Runs `edit` with commands once, as its own run. */ +const edit = System.create({ name: "rulesTest/edit", input: { type: "object", properties: { step: { type: "integer" } } } }, ({ input, commands }) => { + for (const step of steps[input.step] ?? []) step(commands); +}); +let steps: ((commands: System.Commands) => void)[][] = []; +const at = (world: ReturnType) => async (...run: ((commands: System.Commands) => void)[]) => { + steps = [...steps, run]; + await world.run(edit, { step: steps.length - 1 }); +}; + +const all: readonly Component.Key[] = [source, hidden, sprite, slot, visible, ready, glow, owned, follows, mark]; +const components = async (world: ReturnType, entity: Entity) => { + const values = await Promise.all(all.map(key => read(world, entity, key))); + return all.filter((_, i) => values[i] !== null); +}; + +describe("rules", () => { + it("accept a rule declared again the same under its id, as a module evaluated again declares it, and reject a different one", () => { + const rules = programRules().length; + Rule.create(identify({ name: "rulesTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); + expect(() => Rule.create(identify({ name: "rulesTest/slots", components: { has: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); + expect(programRules()).toHaveLength(rules); + }); + it("derive components where they match, from the schema default, and remove them where they stop", async () => { + const world = createWorld({ [workers]: 0 }); + const step = at(world); + let a = Entity.none, b = Entity.none, c = Entity.none; + await step(commands => { + a = commands.spawn([source])({ [source]: 1 }); + b = commands.spawn([source, hidden])({ [source]: 2, [hidden]: true }); + c = commands.spawn([sprite])({ [sprite]: true }); + }); + expect(await components(world, a)).toEqual([source, slot, visible]); + expect(await read(world, a, slot)).toBe(7); + expect(await components(world, b)).toEqual([source, hidden]); + expect(await components(world, c)).toEqual([sprite, visible]); + await step(commands => { commands.add(a, hidden, true); commands.remove(b, hidden); commands.remove(c, sprite); }); + expect(await components(world, a)).toEqual([source, hidden]); + expect(await components(world, b)).toEqual([source, slot, visible]); + expect(await components(world, c)).toEqual([]); + await world.dispose(); + }); + + it("write the default again where a derived component starts over, on a reused row", async () => { + const write = System.create({ name: "rulesTest/write", components: { write: [slot] } }, row => { row[slot] = 99; }); + const world = createWorld({ [workers]: 0 }); + const step = at(world); + let a = Entity.none; + await step(commands => { a = commands.spawn([source])({ [source]: 1 }); commands.spawn([source])({ [source]: 2 }); }); + await world.run(write); + expect(await read(world, a, slot)).toBe(99); + await step(commands => commands.add(a, hidden, true)); + await step(commands => commands.remove(a, hidden)); + expect(await read(world, a, slot)).toBe(7); + await world.dispose(); + }); + + it("apply in a world from the first sync point after it meets their components, deriving on the first spawn", async () => { + const spawn = System.create({ name: "rulesTest/tagged" }, ({ commands }) => { commands.spawn([tagged])({ [tagged]: true }); }); + const world = createWorld({ [workers]: 0 }); + await world.run(edit, { step: -1 }); + expect(await count(world, { required: [tag] })).toBe(0); + await world.run(spawn); + expect(await count(world, { required: [tagged, tag] })).toBe(1); + await world.dispose(); + }); + + it("created after a world has entities they match, derive for them at the next run, and for batch spawns", async () => { + let added = 0; + const observe = System.create({ name: "rulesTest/added", components: { added: [late] } }, () => { added++; }); + const spawn = System.create({ name: "rulesTest/spawn" }, ({ commands }) => { for (let i = 0; i < 3; i++) commands.spawn([mark])({ [mark]: i }); }); + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(observe); + expect(await count(world, { required: [late] })).toBe(0); + Rule.create({ name: "rulesTest/late", components: { has: [mark] }, derive: [late] }); + await world.run(spawn); + expect(await count(world, { required: [late] })).toBe(6); + await world.run(observe); + expect(added).toBe(6); + await world.dispose(); + }); + + it("are rejected when they can't hold", async () => { + // @ts-expect-error a rule derives only derived components + expect(() => Rule.create({ name: "rulesTest/bad", components: { has: [source] }, derive: [mark] })).toThrow(`derives "rulesTest/mark", which isn't declared derived`); + expect(() => Rule.create({ name: "rulesTest/bad", components: { has: [source], not: [visible] }, derive: [slot] })).toThrow("cycle through a negation, which could oscillate"); + // A rejected rule records nothing, so its name labels only the next; a system may share a rule's name. + expect(labelOfItem(Rule.create({ name: "rulesTest/bad", components: { has: [tagged] }, derive: [late] }))).toBe("rulesTest/bad"); + expect(labelOfItem(System.create({ name: "rulesTest/visible" }, () => {}))).toBe("rulesTest/visible#2"); + // @ts-expect-error a reference changes only through commands + expect(() => System.create({ name: "rulesTest/writeReference", components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); + // The component closing a cycle of within declarations is rejected when it's registered. + expect(() => Component.register(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); + }); +}); + +describe("within", () => { + it("removes a dependent with its host, by any cause, transitively, and in command order", async () => { + const world = createWorld({ [workers]: 0 }); + const step = at(world); + let a = Entity.none, b = Entity.none, c = Entity.none; + await step(commands => { + a = commands.spawn([source, ready, glow])({ [source]: 1, [ready]: 1, [glow]: true }); + b = commands.spawn([source, ready])({ [source]: 2, [ready]: 2 }); + c = commands.spawn([mark, ready])({ [mark]: 3, [ready]: 3 }); + }); + expect(await components(world, a)).toEqual([source, slot, visible, ready, glow]); + // Dropped: c has no slot, so its ready can't be. + expect(await components(world, c)).toEqual([mark]); + await step(commands => commands.add(a, hidden, true)); + expect(await components(world, a)).toEqual([source, hidden]); + // Removing a host and adding it back still ends its dependent, as each removal does; a derived host that a + // rule keeps never leaves, so its dependent stays. + await step(commands => { commands.add(b, glow, true); }); + await step(commands => { commands.remove(b, ready); commands.add(b, ready, 5); commands.remove(b, source); commands.add(b, source, 5); }); + expect(await components(world, b)).toEqual([source, slot, visible, ready]); + await world.dispose(); + }); +}); + +describe("references", () => { + it("despawn owned holders and drop weak references when their target despawns, transitively", async () => { + const world = createWorld({ [workers]: 0 }); + const step = at(world); + let root = Entity.none, child = Entity.none, grandchild = Entity.none, watcher = Entity.none, other = Entity.none, moved = Entity.none; + await step(commands => { + root = commands.spawn([mark])({ [mark]: 0 }); + child = commands.spawn([mark, owned])({ [mark]: 1, [owned]: root }); + grandchild = commands.spawn([mark, owned])({ [mark]: 2, [owned]: child }); + watcher = commands.spawn([mark, follows])({ [mark]: 3, [follows]: grandchild }); + other = commands.spawn([mark])({ [mark]: 4 }); + moved = commands.spawn([mark, owned])({ [mark]: 5, [owned]: root }); + }); + await step(commands => { commands.add(moved, owned, other); commands.despawn(root); }); + expect(await read(world, child, mark)).toBeNull(); + expect(await read(world, grandchild, mark)).toBeNull(); + expect(await components(world, watcher)).toEqual([mark]); + // Pointed elsewhere in the same sync point, so it stays. + expect(await read(world, moved, owned)).toBe(other); + await world.dispose(); + }); + + it("leave references to Entity.none alone, since they name no entity", async () => { + const world = createWorld({ [workers]: 0 }); + const step = at(world); + let holder = Entity.none, cleared = Entity.none; + await step(commands => { + holder = commands.spawn([mark, owned, follows])({ [mark]: 1, [owned]: Entity.none, [follows]: Entity.none }); + cleared = commands.spawn([mark, follows])({ [mark]: 2, [follows]: holder }); + }); + await step(commands => commands.add(cleared, follows, Entity.none)); + expect(await read(world, holder, owned)).toBe(Entity.none); + expect(await read(world, holder, follows)).toBe(Entity.none); + expect(await read(world, cleared, follows)).toBe(Entity.none); + await world.dispose(); + }); + + it("cascade at once for a reference to an entity that won't be alive, but not to one spawned with it", async () => { + const world = createWorld({ [workers]: 0 }); + const step = at(world); + let gone = Entity.none, parent = Entity.none, child = Entity.none, orphan = Entity.none, weakly = Entity.none; + await step(commands => { gone = commands.spawn([mark])({ [mark]: 0 }); }); + await step(commands => { + commands.despawn(gone); + parent = commands.spawn([mark])({ [mark]: 1 }); + child = commands.spawn([mark, owned])({ [mark]: 2, [owned]: parent }); + orphan = commands.spawn([mark, owned])({ [mark]: 3, [owned]: gone }); + weakly = commands.spawn([mark, follows])({ [mark]: 4, [follows]: gone }); + }); + expect(await read(world, child, owned)).toBe(parent); + expect(await read(world, orphan, mark)).toBeNull(); + expect(await components(world, weakly)).toEqual([mark]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts new file mode 100644 index 000000000..432aaa6c6 --- /dev/null +++ b/packages/ecs/src/implementation/world/runner.ts @@ -0,0 +1,1073 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import type { Archetype } from './archetype.js'; +import { kernelOf } from './attach-kernel.js'; +import { readCell } from './cells.js'; +import { createCommands } from './commands.js'; +import { bindKernel, type BoundKernel } from './kernels.js'; +import type { Pool, TaskResult } from './pool.js'; +import { maximumScalars, type KernelSetup } from './protocol.js'; +import type { Segment } from './command-ring.js'; +import { createBuffer, createKernelCommands, emptyBuffer, stage, type CommandBuffer } from './buffer.js'; +import { createBatch, positionalSpawner, type SpawnBatch } from './staging.js'; +import { createBlock, endBlock, indexOf, locate, reserveFrom } from './entities.js'; +import { forEachPassingRange, type Filter } from './ranges.js'; +import { resourceCell } from './resources.js'; +import { createContext, parentBinder, rowBinder } from './rows.js'; +import { scalarValue, scalarValues, type WasmKernel } from './wasm.js'; +import { idOf, type State } from './state.js'; +import { accessorsOf } from '../../interface/gpu/accessors.js'; +import { recordWords } from './gpu.js'; +import { maximumWorkgroups, workgroupSize, type WgslKernel } from './wgsl.js'; +import { allocateArray, freeArray } from './arrays.js'; +import { combine, identityOf, reductionTargets, type ReductionTarget } from './reductions.js'; +import { samePath, type TypedArray } from '../../interface/registry/layout.js'; +import { inputFor } from './input.js'; +import { createUp, type Run } from './up.js'; +import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import type { RemoteSystem } from './remote-systems.js'; +import type { JoinedObserver } from './joined-observer.js'; +import { releaseDirectory, type Directory } from './directories.js'; +import type { GpuEngine } from './gpu.js'; +import { acquireLookups, type IndexLookups, type IndexUse } from './indexes.js'; +import { createOrdered, type Ordered, type Passing } from './index-order.js'; +import { blockBase, type SortedTable } from './index-sorted.js'; +import type { Since } from './index-lookups.js'; + +/** + * One unit of a system's work for a run: the commands it issued, or for a worker's chunk its segment of the worker's + * command ring, plus its error if it failed partway. + */ +export type ChunkResult = { readonly commands: CommandBuffer; readonly segment?: Segment; readonly error?: unknown }; +/** An `up` system on workers settles as one chunk, once each depth's pieces have, in turn. */ +export type Chunk = ChunkResult | Promise | Promise; + +/** + * Where kernels may run. Archetypes with fewer than `minimumChunk` rows run on the main thread. + * `next` rotates the first worker of each split, so small archetypes don't all queue on one worker. + */ +export type Workers = { readonly pool: Pool; readonly minimumChunk: number; next: number }; + +/** 16 rows of f32 is a 64-byte line, so chunks of different workers never share one. */ +const chunkAlignment = 16; +/** Pieces per worker in a claimed split: enough that faster workers can take more of them. */ +const claimsPerWorker = 8; + +const noPartials = new Float64Array(0); +const noIndexParameters: readonly number[] = []; +const noAddresses: readonly number[] = []; + +/** An archetype's change filters for a runner, and the WASM addresses of its columns and filters (`filterCache`). */ +type Filtered = { readonly version: number; readonly filters: readonly Filter[]; addresses: readonly number[]; addressed: WasmKernel | null }; + +/** + * Runs one leaf system. Each run takes a new tick, which becomes the system's `lastRun`. `start` does main-thread work + * immediately and posts kernel chunks to workers; the chunks' commands merge in returned order. + */ +export type Runner = { + readonly kernel: BoundKernel | null; + /** Where it runs: the CPU, the GPU as a placed kernel, or the GPU as a render system recording its own work. */ + readonly placement: "cpu" | "gpu" | "render"; + /** Component ids it reads or writes in its query, those it writes, and those its lookups read in any archetype. */ + readonly touches: readonly number[]; + readonly writes: readonly number[]; + readonly looks: readonly number[]; + /** Resource ids it writes or reduces into, which a tail reading them waits for (`tails.ts`). */ + readonly resourceWrites: readonly number[]; + /** The archetypes its query matches now. */ + readonly matched: () => readonly Archetype[]; + /** The indexes its lookups go through, which a run refreshes before its tier. */ + readonly indexes: readonly IndexUse[]; + /** Loads its kernel, and compiles its compute pipeline when it's placed on the GPU; rejects with the reason it can't be. */ + readonly load: () => Promise; + /** What workers need to load this system's kernel, given its index. */ + readonly setup: (index: number) => KernelSetup | null; + readonly start: (args: unknown, workers: Workers | null, index: number) => readonly Chunk[]; + /** The resources it reduces into, in the order `finish` gives their folds. */ + readonly targets: readonly ReductionTarget[]; + /** + * Once a run's chunks settle: the leaves of its fold into each target, or null when it failed or skipped its rows, + * so it contributes nothing. + */ + readonly finish: (failed: boolean) => readonly Float64Array[] | null; + /** For the registrar to call after each sync point once the runner is registered: sizes its reduction partials. */ + readonly afterSync: (() => void) | null; + /** What it holds that the world must release once nothing runs it again. */ + readonly holdings: Holdings; + /** For a system placed on a joined thread, which runs it there (`remote-systems.ts`); null for one that runs here. */ + readonly remote: RemoteSystem | null; + /** For an observer system on a joined thread, which runs it there after sync points (`joined-observer.ts`); else null. */ + readonly joined: JoinedObserver | null; + /** The tick it last ran at, which a runner placing it elsewhere starts from. */ + readonly lastRun: () => number; + /** + * For a runner the registrar may keep once its system is removed, to run it again when it's registered again: one + * here, of an observer system, holding nothing beyond its JS values. Makes it as a new runner from tick `since`. + */ + readonly restart: ((since: number) => void) | null; +}; + +/** + * What a runner holds beyond its own JS values: blocks of the arena, the world's records of it, and its hook after each + * sync point, held weakly. Plain data that holds nothing of its system, so the registrar can release it once the system + * is collected or removed, and keep it until then without keeping the system. + */ +export type Holdings = { + /** Set once its system is removed, so no run calls its body again, its rows' included. */ + removed: boolean; + released: boolean; + /** Its kernel's reduction partials, in the arena. */ + partials: Float64Array | null; + /** Its `up` order, in the arena, and its reading of the journal; or its rows in an index's order, in the arena. */ + readonly up: { readonly release: () => void } | null; + /** Its kernel's lookup directories, which the world keeps current. */ + readonly directories: readonly Directory[]; + /** The indexes its lookups go through, held while it lives. */ + readonly indexes: IndexLookups | null; + /** The GPU it last recorded on, and the prefix of its records' keys there. */ + gpu: GpuEngine | null; + readonly prefix: string; + readonly afterSync: WeakRef<() => void> | null; + /** For a system placed on a joined thread: what stops it running there once it's removed. */ + remote: { readonly unplace: () => void } | null; +}; + +/** Whether holdings hold nothing beyond their runner's JS values, so releasing them frees nothing. */ +const holdsNothing = (holdings: Holdings): boolean => + holdings.partials === null && holdings.up === null && holdings.directories.length === 0 && (holdings.indexes === null || holdings.indexes.uses.length === 0) + && holdings.gpu === null && holdings.afterSync === null && holdings.remote === null; + +/** Holdings of a runner that holds nothing beyond its JS values. */ +export const holdingNothing = (remote: Holdings["remote"] = null): Holdings => ({ removed: false, released: false, partials: null, up: null, directories: [], indexes: null, gpu: null, prefix: "", afterSync: null, remote }); + +/** + * Stops a runner at once, as its system is removed: no run calls its body again, and on a joined thread no task runs it + * once this returns. What it holds is released later, once no run in progress uses it. + */ +export const stopHoldings = (holdings: Holdings): void => { + holdings.removed = true; + const { remote } = holdings; + holdings.remote = null; + remote?.unplace(); +}; + +/** Releases what a runner holds, once: no run may use it again. */ +export const releaseHoldings = (state: State, holdings: Holdings): void => { + if (holdings.released) return; + holdings.released = true; + stopHoldings(holdings); + if (holdings.partials !== null) freeArray(state.memory, holdings.partials); + holdings.partials = null; + holdings.up?.release(); + for (const directory of holdings.directories) releaseDirectory(state, directory); + holdings.indexes?.release(); + // Records on a GPU the world has since left went with it. + if (holdings.gpu !== null && holdings.gpu === state.gpu) holdings.gpu.release(holdings.prefix); + if (holdings.afterSync !== null) state.afterSync.delete(holdings.afterSync); +}; + +/** The resources a declaration writes or reduces into, by id. */ +export const resourceWritesOf = (state: State, declaration: Leaf["declaration"]): readonly number[] => + [...new Set([...declaration.resources?.write ?? [], ...declaration.reduce === undefined ? [] : Object.getOwnPropertySymbols(declaration.reduce)].map(key => idOf(state, key)))]; + +/** A number for each leaf, so the GPU records of every runner made for it are its own, whatever its name. */ +const leafNumbers = new WeakMap(); +let leafCount = 0; +const numberOf = (leaf: Leaf): number => { + let number = leafNumbers.get(leaf); + if (number === undefined) leafNumbers.set(leaf, number = leafCount++); + return number; +}; + +/** Throws if the leaf is a kernel that wasn't compiled. `since` is the tick it last ran at, wherever it ran. */ +export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { + const { declaration } = leaf; + const leafNumber = numberOf(leaf); + const label = labelOfItem(leaf); + // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. + const system: { body(subject: unknown, context?: unknown): void } = leaf; + const { components: query, resources } = declaration; + const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(key => idOf(state, key)); + const excluded = (query?.not ?? []).map(key => idOf(state, key)); + const addedIds = (query?.added ?? []).map(key => idOf(state, key)); + const changedIds = (query?.changed ?? []).map(key => idOf(state, key)); + const resourceChanged = resources?.changed ?? []; + const indexLookups = acquireLookups(state, declaration.lookup, declaration.order); + /** The indexes whose entries changing passes every row, as a changed resource does: its lookups' with `changed: true`. */ + const indexFilters = indexLookups.filters; + const indexFiltered = indexFilters.length > 0; + const filtered = addedIds.length + changedIds.length + resourceChanged.length + indexFilters.length > 0; + /** Its `lastRun` while it runs, which its lookups' lists and rows compare with. */ + const lookupSince: Since = { value: since }; + let kernel: BoundKernel | null = null; + if (declaration.kernel !== undefined) { + const description = kernelOf(leaf); + if (description === undefined) { + indexLookups.release(); + throw new Error(`System "${label}" is a kernel but wasn't compiled; add kernelPlugin() to the Vite config.`); + } + try { + kernel = bindKernel(state, leaf, description, indexLookups.byName, lookupSince); + } + catch (error) { + indexLookups.release(); + throw error; + } + } + /** A kernel's lookups through indexes' WASM parameters for the current run, as `refreshIndexes` gave them at its start. */ + let indexParameters: readonly number[] = noIndexParameters; + const indexed = kernel?.indexes.some(found => found !== null) === true; + const placement = leaf.kind === "render" ? "render" : declaration.kernel === "gpu" && state.gpu !== null ? "gpu" : "cpu"; + const failPlacement = (message: string): never => { throw new Error(`System "${label}" ${message}.`); }; + // Creating the system and registering its kernel checked its placement, reductions and accessors. + const up = declaration.up === undefined ? null : createUp(state, declaration.up, filtered); + /** Its rows in its `order` index's order, run by run. */ + const ordered = indexLookups.ordered === null ? null : createOrdered(state.memory); + const targets = reductionTargets(state, declaration); + /** For each partial of the kernel, the target and leaf it folds into. */ + const partialLeaves = (kernel?.description.reductions ?? []).map(reduced => { + const index = targets.findIndex(target => target.key === reduced.resource); + const leaf = targets[index]?.leaves.findIndex(candidate => samePath(candidate.path, reduced.path)) ?? -1; + return leaf < 0 ? failPlacement(`contributes to "${labelOf(reduced.resource)}", which it doesn't reduce`) : { target: index, leaf }; + }); + const accessors = declaration.shader === undefined ? [] : accessorsOf(declaration.shader); + if (typeof accessors === "string") return failPlacement(`can't render: ${accessors}`); + const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); + const writes = (query?.write ?? []).map(key => idOf(state, key)); + const looks = [...new Set([...Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []), ...declaration.up?.read ?? []])].map(key => idOf(state, key)); + /** Record keys by archetype. */ + const keys = new Map(); + const keyOf = (archetype: Archetype): string => { + let key = keys.get(archetype); + if (key === undefined) keys.set(archetype, key = `${leafNumber}:${archetype.id}`); + return key; + }; + let pipeline: { readonly compute: GPUComputePipeline; readonly kernel: WgslKernel } | null = null; + /** This system's buffers, reused each run: the sync point clears them. A run starts on the first. */ + const owned: CommandBuffer[] = [createBuffer()]; + const holder: { buffer: CommandBuffer } = { buffer: owned[0] }; + let used = 0; + const nextBuffer = (): CommandBuffer => owned[used++] ??= createBuffer(); + /** Each owned buffer as a chunk, made once. */ + const ownedChunks: ChunkResult[] = [{ commands: owned[0] }]; + /** + * The kernel's spawn batches on the main thread, one per set of components its lists name, emptied at the start of + * each run, and by batch the first row the current buffer hasn't staged yet. + */ + const spawnBatches: SpawnBatch[] = []; + const unstaged: number[] = []; + /** The spawners' entities, reserved in blocks, ended by each run. */ + const block = createBlock(state.entities); + const spawners = (kernel?.setup.spawns ?? []).map(ids => { + const key = [...ids].sort((a, b) => a - b).join(","); + let batch = spawnBatches.find(candidate => candidate.ids.join(",") === key); + if (batch === undefined) { + batch = createBatch(state.layouts, ids); + spawnBatches.push(batch); + unstaged.push(0); + } + return positionalSpawner(block, reserveFrom, batch, ids, ids.map(id => state.starts[id])); + }); + /** Whether the kernel spawns, so runs of every other system skip staging. */ + const spawns = spawnBatches.length > 0; + /** Stages in the current buffer the rows spawned since it took over, so they commit in its place in merge order. */ + const stageSpawns = (): void => { + for (let b = 0; b < spawnBatches.length; b++) { + const { count } = spawnBatches[b]; + if (count === unstaged[b]) continue; + stage(holder.buffer, spawnBatches[b], unstaged[b], count); + unstaged[b] = count; + } + }; + /** Moves `holder` to the run's next buffer, returning it as a chunk. */ + const nextChunk = (): ChunkResult => { + if (spawns) stageSpawns(); + const index = used; + holder.buffer = nextBuffer(); + return ownedChunks[index] ??= { commands: holder.buffer }; + }; + /** The chunks of the current run, reused by the next once its tier has taken them. */ + const runChunks: Chunk[] = []; + const noChunks: readonly Chunk[] = []; + const noValues: readonly unknown[] = []; + /** Whether a resource a `changed` filter names changed since the system last ran. */ + const resourceChangedIds = resourceChanged.map(key => idOf(state, key)); + const resourcesChangedSince = (since: number): boolean => { + const index = indexOf(state.resourceEntity); + const archetype = state.archetypes[state.entities.archetype[index]]; + const row = state.entities.row[index]; + for (let k = 0; k < resourceChangedIds.length; k++) { + const storage = archetype?.storageById[resourceChangedIds[k]]; + // Throws, as for a resource not in this world. + if (storage === undefined) resourceCell(state, resourceChanged[k]); + else if (storage.changed[row] > since) return true; + } + return false; + }; + const commands = createCommands(state, holder); + const kernelCommands = createKernelCommands(holder, kernel?.setup.keys ?? [], kernel?.setup.starts ?? [], spawners); + // A kernel's reductions fold in its partials, so only a body that runs as written gets reducer functions. Render + // bodies, GPU kernels and observer systems issue no commands. + const ownInput = inputFor(leaf); + const context = createContext(state, declaration, { + targets: kernel === null ? targets : [], + commands: leaf.kind === "render" || declaration.kernel === "gpu" || declaration.observer === true ? undefined : commands, + input: declaration.input !== undefined, + indexes: indexLookups.byName, + since: lookupSince, + }); + /** Partials of the kernel's reductions, one slot per kernel call of a run, in the order its rows run. */ + const partialCount = partialLeaves.length; + let slots = noPartials; + let slotsUsed = 0; + /** Whether the current run ran its rows, so its reductions are written. */ + let ran = false; + /** + * The most kernel calls a run gives an archetype: one on the main thread; across workers a piece of at least + * `chunkAlignment` rows each, at most `claimsPerWorker` per worker. + */ + const slotsFor = (count: number): number => + count === 0 ? 0 : state.workers > 0 ? Math.min(state.workers * claimsPerWorker, Math.ceil(count / chunkAlignment)) : 1; + /** Sized when archetypes change, at registration and sync points, so runs don't allocate or grow memory. */ + const sizeSlots = (): void => { + if (partialCount === 0) return; + refreshMatches(); + let needed = 0; + for (const archetype of matches) needed += slotsFor(archetype.count); + needed = Math.max(1, needed) * partialCount; + if (slots.length >= needed) return; + if (slots.length > 0) freeArray(state.memory, slots); + slots = holdings.partials = allocateArray(state.memory, Float64Array, Math.max(needed, 2 * slots.length)); + }; + /** The first of `count` slots for the next kernel calls, each starting at its operators' identities. */ + const takeSlots = (count: number): number => { + const first = slotsUsed; + slotsUsed += count; + // Archetypes change only at sync points, which size the slots for them. + if (slotsUsed * partialCount > slots.length) throw new Error(`System "${label}" has no room for its reduction partials.`); + for (let slot = first; slot < slotsUsed; slot++) { + partialLeaves.forEach(({ target }, k) => { slots[slot * partialCount + k] = identityOf(targets[target]?.operator ?? "+"); }); + } + return first; + }; + /** Each target's leaves, folded from the partials in `finish`. */ + const folded = targets.map(target => new Float64Array(target.leaves.length)); + const partialAddress = (slot: number): number => partialCount === 0 ? -1 : slots.byteOffset + slot * partialCount * 8; + const matches: Archetype[] = []; + let checked = 0; + const bindRow = rowBinder(state, query?.read ?? [], query?.write ?? []); + const bindParent = parentBinder(state, declaration.up?.read ?? []); + let lastRun = since; + + function refreshMatches(): void { + for (; checked < state.archetypes.length; checked++) { + const archetype = state.archetypes[checked]; + if (!archetype.resource && !(archetype.meta && required.length === 0) && required.every(id => archetype.storageOf.has(id)) && !excluded.some(id => archetype.storageOf.has(id))) matches.push(archetype); + } + } + + /** Every row of an archetype passes when a `changed` component's whole column changed since `since`. */ + const sinceFor = (archetype: Archetype, since: number): number => { + if (since < 0) return since; + for (const id of changedIds) if ((archetype.storageById[id]?.columnChanged ?? 0) > since) return -1; + return since; + }; + + /** + * By archetype: its change filters, `added` then `changed`, rows and chunks, kept until its arrays are replaced; + * and the WASM addresses of its columns and filters, for the WASM kernel `addressed` they were taken for, since a + * kernel loaded again may stamp a tick column the last one muted. + */ + const filterCache = new Map(); + const noFilters: readonly Filter[] = []; + + const filteredOf = (archetype: Archetype) => { + const cached = filterCache.get(archetype); + if (cached !== undefined && cached.version === archetype.version) return cached; + const filters = addedIds.length + changedIds.length === 0 ? noFilters : [ + ...addedIds.flatMap(id => { + const storage = archetype.storageById[id]; + return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded }]; + }), + ...changedIds.flatMap(id => { + const storage = archetype.storageById[id]; + return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged }]; + }), + ]; + const entry: Filtered = { version: archetype.version, filters, addresses: noAddresses, addressed: null }; + filterCache.set(archetype, entry); + return entry; + }; + + const filtersOf = (archetype: Archetype): readonly Filter[] => filteredOf(archetype).filters; + + const { target } = declaration; + /** Runs the body on `entity`'s row, when it's alive and its archetype matches. */ + const runTarget = (entity: unknown): void => { + if (typeof entity !== "number") return; + const index = locate(state.entities, entity); + if (index < 0) return; + const archetype = state.archetypes[state.entities.archetype[index]]; + if (archetype === undefined || !matches.includes(archetype)) return; + const row = bindRow(archetype); + row.index = state.entities.row[index]; + system.body(row.value, context.value); + }; + + const runBody = (archetype: Archetype, since: number): void => { + const row = bindRow(archetype); + const { value } = row; + const values = context.value; + const filters = filtersOf(archetype); + // Every row passes, so nothing is filtered. + // A body may remove its own system, after which no row of it runs. + if (since < 0 || filters.length === 0) { + for (let index = 0, count = archetype.count; index < count && !holdings.removed; index++) { + row.index = index; + system.body(value, values); + } + return; + } + forEachPassingRange(filters, since, 0, archetype.count, (start, end) => { + for (let index = start; index < end && !holdings.removed; index++) { + row.index = index; + system.body(value, values); + } + }); + }; + + /** The kernel applies the change filters itself, chunk by chunk, so a range runs in one call. */ + /** A WASM call's arguments, filled in place for each call, so a call allocates nothing. */ + const wasmArguments: number[] = []; + + const runWasm = (bound: BoundKernel, archetype: Archetype, start: number, end: number, since: number, values: readonly unknown[], args: unknown, tick: number, partials: number): void => { + const compiled = bound.compiled(); + if (compiled === null || bound.wasm === null) return; + const entry = filteredOf(archetype); + // Column and filter addresses: the columns are kept per archetype version and load too. + if (entry.addressed !== bound.wasm) { + entry.addresses = [ + ...bound.columnsOf(archetype).map(column => column.byteOffset), + ...entry.filters.map(filter => filter.ticks.byteOffset), + ...entry.filters.map(filter => filter.chunks.byteOffset), + ]; + entry.addressed = bound.wasm; + } + const { addresses } = entry; + const lookups = bound.lookupParameters(); + const { scalars } = bound.wasm; + const call = wasmArguments; + call.length = 4 + addresses.length + lookups.length + indexParameters.length + scalars.length + (partials < 0 ? 0 : 1); + call[0] = start; + call[1] = end; + call[2] = tick; + call[3] = since; + let at = 4; + for (let k = 0; k < addresses.length; k++) call[at++] = addresses[k]; + for (let k = 0; k < lookups.length; k++) call[at++] = lookups[k]; + for (let k = 0; k < indexParameters.length; k++) call[at++] = indexParameters[k]; + for (let k = 0; k < scalars.length; k++) call[at++] = scalarValue(scalars[k], values, args); + if (partials >= 0) call[at] = partials; + Reflect.apply(compiled.run, undefined, call); + }; + + const runKernelLocally = (bound: BoundKernel, archetype: Archetype, since: number, values: readonly unknown[], args: unknown, tick: number): void => { + const slot = partialCount === 0 ? 0 : takeSlots(1); + if (bound.compiled() !== null && bound.wasm !== null) return runWasm(bound, archetype, 0, archetype.count, since, values, args, tick, partialAddress(slot)); + const columns = bound.columnsOf(archetype); + const filters = filtersOf(archetype); + const run = bound.kernel(); + forEachPassingRange(filters, since, 0, archetype.count, (start, end) => + run(columns, start, end, values, bound.lookups, bound.description.keys, args, kernelCommands, tick, slots, slot * partialCount)); + }; + + const noColumns: readonly TypedArray[] = []; + + /** + * Runs rows `rows[start]` to `rows[end - 1]` of one archetype, whose parents are rows `parents[n]` of the archetype + * with id `parentArchetype`, or -1 when they have none with every component `up` reads. + */ + const runUpRows = (archetype: Archetype, parentArchetype: number, rows: Int32Array, parents: Int32Array, start: number, end: number, values: readonly unknown[], args: unknown, tick: number, slot: number): void => { + if (kernel !== null && kernel.compiled() !== null && kernel.wasm !== null) { + const parentColumns = parentArchetype < 0 ? noColumns : kernel.upColumnsOf(state.archetypes[parentArchetype]); + const ups = kernel.description.upColumns ?? []; + kernel.compiled()?.run(start, end, tick, -1, + ...kernel.columnsOf(archetype).map(column => column.byteOffset), + ...kernel.lookupParameters(), + ...indexParameters, + rows.byteOffset, parents.byteOffset, parentArchetype >= 0 ? 1 : 0, + ...ups.map((_, k) => parentColumns[k]?.byteOffset ?? 0), + ...scalarValues(kernel.wasm.scalars, values, args), + ...partialCount === 0 ? [] : [partialAddress(slot)]); + return; + } + if (kernel !== null) { + const parentColumns = parentArchetype < 0 ? noColumns : kernel.upColumnsOf(state.archetypes[parentArchetype]); + kernel.kernel()(kernel.columnsOf(archetype), start, end, values, kernel.lookups, kernel.description.keys, args, kernelCommands, tick, slots, slot * partialCount, rows, parents, parentColumns, parentArchetype >= 0); + return; + } + if (up === null) return; + const row = bindRow(archetype); + // The parent's row is bound after the system's own, for the archetype both may share. + const parentRow = parentArchetype < 0 ? null : bindParent(state.archetypes[parentArchetype]); + const upContext = context.value; + upContext.up = parentRow === null ? null : parentRow.value; + for (let n = start; n < end && !holdings.removed; n++) { + row.index = rows[n]; + if (parentRow !== null) parentRow.index = parents[n]; + system.body(row.value, upContext); + } + }; + + /** The tick rows pass after for `runOrdered`'s filters, as `sinceFor` gives it, and those filters. */ + let orderSince = -1; + const orderPassing: Passing = { filters: filtersOf, since: archetype => sinceFor(archetype, orderSince) }; + + /** + * Runs the rows the `order` index holds in its order, those that pass the change filters, run by run of one + * archetype, on this thread: commands and reductions follow the index's order. + */ + const runOrdered = (order: Ordered, since: number, values: readonly unknown[], args: unknown, tick: number): void => { + const sorted = indexLookups.ordered?.sorted ?? null; + if (sorted === null) return; + if (kernel === null && since < 0) return runBodiesInOrder(order, sorted); + orderSince = since; + const runs = order.runs(sorted, matches, since < 0 ? null : orderPassing); + const slot = partialCount === 0 ? 0 : takeSlots(1); + const contextValue = context.value; + for (let k = 0; k < runs.length && !holdings.removed; k++) { + const run = runs[k]; + const archetype = matches[run.owner]; + if (kernel !== null) { + runUpRows(archetype, -1, run.rows, run.parents, run.start, run.end, values, args, tick, slot); + continue; + } + const row = bindRow(archetype); + const { value } = row; + for (let n = run.start; n < run.end && !holdings.removed; n++) { + row.index = run.rows[n]; + system.body(value, contextValue); + } + } + }; + + /** Every row the `order` index holds of the matches, walking its entries, its body bound again where the archetype changes. */ + const runBodiesInOrder = (order: Ordered, sorted: SortedTable): void => { + const owners = order.owners(matches); + const { entries, stride, words, lengths, blocks } = sorted; + const contextValue = context.value; + let current = -1; + let row: ReturnType | null = null; + let value: object | null = null; + for (let k = 0; k < blocks && !holdings.removed; k++) { + const block = sorted.order[k]; + const base = blockBase(sorted, block) + words; + for (let j = 0, length = lengths[block]; j < length && !holdings.removed; j++) { + const at = base + j * stride; + const archetype = entries[at + 1]; + const owner = archetype < owners.length ? owners[archetype] : -1; + if (owner < 0) continue; + if (owner !== current || row === null || value === null) { + row = bindRow(matches[owner]); + value = row.value; + current = owner; + } + row.index = entries[at + 2]; + system.body(value, contextValue); + } + } + }; + + /** Posts listed rows to workers as claimed pieces of a WASM kernel, which takes `up`'s addresses after the lookups. */ + const postUp = (workers: Workers, index: number, run: Run, values: readonly unknown[], args: unknown, tick: number): Promise[] => { + if (kernel === null || kernel.wasm === null) return []; + const parentColumns = run.parent < 0 ? noColumns : kernel.upColumnsOf(state.archetypes[run.parent]); + const scalars = [ + ...kernel.lookupParameters(), + ...indexParameters, + run.rows.byteOffset, run.parents.byteOffset, run.parent >= 0 ? 1 : 0, + ...(kernel.description.upColumns ?? []).map((_, k) => parentColumns[k]?.byteOffset ?? 0), + ...scalarValues(kernel.wasm.scalars, values, args), + ]; + const count = run.end - run.start; + const { pool } = workers; + const participants = Math.min(pool.size, Math.ceil(count / minimumFor(workers))); + const first = workers.next; + workers.next = (first + participants) % pool.size; + const grain = Math.ceil(count / (participants * claimsPerWorker) / chunkAlignment) * chunkAlignment; + return pool.claimed(first, participants, { kernel: index, archetype: matches[run.owner].id, start: run.start, end: run.end, tick, lastRun: -1, scalars, partials: -1, partialBytes: 0 }, grain); + }; + + /** + * Runs an `up` system's rows depth by depth from the roots, each depth's groups on the main thread or, for a WASM + * kernel without reductions, across workers, settling before the next depth starts. With change filters, only the + * rows `up` marks run: those passing the filters, and those whose parent changed. Returns a chunk when workers run + * any of it. + */ + const runUp = (since: number, values: readonly unknown[], args: unknown, tick: number, workers: Workers | null, index: number): Promise | null => { + if (up === null) return null; + const slot = partialCount === 0 ? 0 : takeSlots(1); + let next: () => readonly Run[] | null; + let clear = (): void => {}; + if (since < 0) { + const order = up.levels(matches); + let depth = 0; + next = () => { + if (depth >= order.levels) return null; + const runs: Run[] = []; + for (let group = order.depthGroups[depth]; group < order.depthGroups[depth + 1]; group++) { + if (order.groupEnds[group] === order.groupStarts[group]) continue; + runs.push({ owner: order.groupOwners[group], parent: order.groupParents[group], rows: order.rows, parents: order.parents, start: order.groupStarts[group], end: order.groupEnds[group] }); + } + depth++; + return runs; + }; + } + else { + const pass = up.filtered(matches, addedIds, changedIds, since); + if (pass === null) return null; + next = pass.next; + clear = pass.clear; + } + const runLocally = (run: Run): void => runUpRows(matches[run.owner], run.parent, run.rows, run.parents, run.start, run.end, values, args, tick, slot); + const parallel = workers !== null && kernel !== null && kernel.wasm !== null && kernel.compiled() !== null && partialCount === 0 + && kernel.lookupParameters().length + indexParameters.length + 3 + (kernel.description.upColumns?.length ?? 0) + kernel.wasm.scalars.length <= maximumScalars; + if (!parallel || workers === null) { + try { + for (let runs = next(); runs !== null; runs = next()) for (const run of runs) runLocally(run); + } + finally { + clear(); + } + return null; + } + return (async (): Promise => { + const results: ChunkResult[] = []; + try { + for (let runs = next(); runs !== null; runs = next()) { + const pending: Promise[] = []; + for (const run of runs) { + if (run.end - run.start >= minimumFor(workers)) pending.push(...postUp(workers, index, run, values, args, tick)); + else runLocally(run); + } + // A depth run on the main thread alone needs no wait. + if (pending.length === 0) continue; + const settled = await Promise.all(pending); + results.push(...settled); + // A failed depth stops the deeper ones, which would read what it didn't write. + if (settled.some(result => result.error !== undefined)) return results; + } + } + catch (error) { + results.push({ commands: emptyBuffer, error }); + } + finally { + clear(); + } + return results; + })(); + }; + + /** WASM kernels with few enough scalars dispatch through shared memory. */ + const isFast = (bound: BoundKernel | null): boolean => + bound !== null && bound.wasm !== null && bound.compiled() !== null + && bound.lookupParameters().length + indexParameters.length + bound.wasm.scalars.length + (partialCount > 0 ? 2 : 0) <= maximumScalars; + /** Rows below which an archetype runs on the main thread. Shared-memory dispatch is cheap enough that half the size pays off. */ + const minimumFor = (workers: Workers): number => isFast(kernel) ? workers.minimumChunk / 2 : workers.minimumChunk; + + /** + * Splits an archetype's rows across workers, at least `minimumChunk` rows each. WASM kernels claim pieces, from + * home ranges and then from each other, so cores of different speeds finish together; others take even shares. + */ + const postChunks = (workers: Workers, index: number, archetype: Archetype, since: number, values: readonly unknown[], args: unknown, tick: number): Chunk[] => { + const { count } = archetype; + const { pool } = workers; + const bound = kernel; + const scalars = bound !== null && bound.wasm !== null ? [...bound.lookupParameters(), ...indexParameters, ...scalarValues(bound.wasm.scalars, values, args)] : []; + const fast = isFast(bound); + const participants = Math.min(pool.size, Math.ceil(count / minimumFor(workers))); + const aligned = (rows: number): number => Math.ceil(rows / chunkAlignment) * chunkAlignment; + // WASM chunks go through shared memory. The main thread doesn't take one: measured, it finishes later than an + // idle worker. + if (fast) { + // A split over every worker maps home ranges the same way each run, so each worker gets back the rows it + // last ran and finds them in its cache; smaller splits rotate, so small archetypes don't queue on one + // worker. + const full = participants === pool.size; + const first = full ? 0 : workers.next; + if (!full) workers.next = (first + participants) % pool.size; + const grain = aligned(Math.ceil(count / (participants * claimsPerWorker))); + // One slot per piece, so the fold's order is the pieces', whichever worker runs each. + const firstSlot = partialCount === 0 ? 0 : takeSlots(Math.ceil(count / grain)); + return pool.claimed(first, participants, { kernel: index, archetype: archetype.id, start: 0, end: count, tick, lastRun: since, scalars, partials: partialAddress(firstSlot), partialBytes: partialCount * 8 }, grain); + } + const size = aligned(Math.ceil(count / participants)); + const chunks: Chunk[] = []; + for (let start = 0; start < count; start += size) { + const message = { + kind: "run", kernel: index, archetype: archetype.id, start, end: Math.min(count, start + size), tick, lastRun: since, resources: values, args, + partials: partialCount === 0 ? -1 : partialAddress(takeSlots(1)), lookups: bound === null ? noIndexParameters : bound.lookupParameters(), indexes: indexParameters, + } as const; + const worker = workers.next; + workers.next = (worker + 1) % pool.size; + chunks.push(pool.request(worker, message)); + } + return chunks; + }; + + const words = new Uint32Array(recordWords); + const floats = new Float32Array(words.buffer); + + /** One dispatch per archetype, each with its record of row count, column addresses and scalars. */ + const runOnGpu = (values: readonly unknown[], args: unknown, tick: number): void => { + const gpu = state.gpu; + if (gpu === null || pipeline === null || kernel === null) return failPlacement("isn't loaded on the GPU"); + holdings.gpu = gpu; + const { sources } = kernel.setup; + const scalars = scalarValues(pipeline.kernel.scalars, values, args); + const dispatches: { readonly offset: number; readonly count: number }[] = []; + for (const archetype of matches) { + if (archetype.count === 0) continue; + words[0] = archetype.count; + sources.forEach((source, k) => { + const storage = source.kind === "field" ? archetype.storageById[source.id] : undefined; + words[1 + k] = storage === undefined ? 0 : gpu.upload(archetype, storage, tick); + }); + scalars.forEach((value, s) => { floats[1 + sources.length + s] = value; }); + dispatches.push({ offset: gpu.record(keyOf(archetype), words), count: archetype.count }); + } + if (dispatches.length === 0) return; + const pass = gpu.encoder().beginComputePass(); + pass.setPipeline(pipeline.compute); + for (const { offset, count } of dispatches) { + pass.setBindGroup(0, gpu.computeGroup(), [offset]); + const groups = Math.ceil(count / workgroupSize); + const x = Math.min(groups, maximumWorkgroups); + pass.dispatchWorkgroups(x, Math.ceil(groups / x)); + } + pass.end(); + for (const archetype of matches) { + if (archetype.count === 0) continue; + for (const id of writes) { + const storage = archetype.storageById[id]; + if (storage === undefined) continue; + gpu.markGpuWrites(archetype, storage); + // Rows a GPU kernel writes are stamped as one, for the whole column. + if (storage.ticked) storage.columnChanged = tick; + } + } + }; + + /** The component ids of the parent columns an `up` kernel reads, in order. */ + const upIds = (kernel?.description.upColumns ?? []).map(column => column.kind === "field" ? idOf(state, column.component) : -1); + /** The order whose row lists the GPU holds. */ + let uploadedOrder: object | null = null; + + /** + * An `up` kernel on the GPU: a dispatch per group, in order, in one pass, which keeps each depth's writes visible + * to the next. Each record has the group's row count, its archetype's columns, its rows' and parents' rows' + * addresses, whether it has a parent, the parent archetype's columns, and the scalars. + */ + const runUpOnGpu = (values: readonly unknown[], args: unknown, tick: number): void => { + const gpu = state.gpu; + if (gpu === null || pipeline === null || kernel === null || up === null) return failPlacement("isn't loaded on the GPU"); + holdings.gpu = gpu; + const order = up.levels(matches); + const fresh = order !== uploadedOrder; + const rowsAddress = gpu.uploadIndexes(`${leafNumber}:rows`, order.rows, order.size, fresh); + const parentsAddress = gpu.uploadIndexes(`${leafNumber}:parents`, order.parents, order.size, fresh); + uploadedOrder = order; + const { sources } = kernel.setup; + const scalars = scalarValues(pipeline.kernel.scalars, values, args); + const dispatches: { readonly offset: number; readonly count: number }[] = []; + for (let group = 0; group < order.groups; group++) { + const start = order.groupStarts[group]; + const count = order.groupEnds[group] - start; + if (count === 0) continue; + const archetype = matches[order.groupOwners[group]]; + const parentId = order.groupParents[group]; + const parent = parentId < 0 ? null : state.archetypes[parentId]; + words[0] = count; + sources.forEach((source, k) => { + const storage = source.kind === "field" ? archetype.storageById[source.id] : undefined; + words[1 + k] = storage === undefined ? 0 : gpu.upload(archetype, storage, tick); + }); + const base = 1 + sources.length; + words[base] = rowsAddress + start; + words[base + 1] = parentsAddress + start; + words[base + 2] = parent === null ? 0 : 1; + upIds.forEach((id, k) => { + const storage = parent?.storageById[id]; + words[base + 3 + k] = parent === null || storage === undefined ? 0 : gpu.upload(parent, storage, tick); + }); + scalars.forEach((value, s) => { floats[base + 3 + upIds.length + s] = value; }); + dispatches.push({ offset: gpu.record(`${leafNumber}:up:${group}`, words), count }); + } + if (dispatches.length > 0) { + const pass = gpu.encoder().beginComputePass(); + pass.setPipeline(pipeline.compute); + for (const { offset, count } of dispatches) { + pass.setBindGroup(0, gpu.computeGroup(), [offset]); + const groups = Math.ceil(count / workgroupSize); + const x = Math.min(groups, maximumWorkgroups); + pass.dispatchWorkgroups(x, Math.ceil(groups / x)); + } + pass.end(); + } + for (const archetype of matches) { + if (archetype.count === 0) continue; + for (const id of writes) { + const storage = archetype.storageById[id]; + if (storage === undefined) continue; + gpu.markGpuWrites(archetype, storage); + if (storage.ticked) storage.columnChanged = tick; + } + } + }; + + const runRender = (tick: number): void => { + const gpu = state.gpu; + // The device may be set to null after the system registered. + if (gpu === null) return failPlacement("needs a world with a gpu device"); + if (typeof accessors === "string") return; + holdings.gpu = gpu; + const group = declaration.shader?.group ?? 0; + const tables = matches.filter(archetype => archetype.count > 0).map(archetype => { + words[0] = archetype.count; + accessors.forEach((accessor, k) => { + const storage = archetype.storageById[idOf(state, accessor.component)]; + words[1 + k] = storage === undefined ? 0 : gpu.upload(archetype, storage, tick); + }); + const offset = gpu.record(keyOf(archetype), words); + return { count: archetype.count, bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => pass.setBindGroup(group, gpu.renderGroup(), [offset]) }; + }); + // Work recorded so far is submitted first, so a body that fails partway loses only its own. + gpu.submit(); + try { + system.body({ device: gpu.device, encoder: gpu.encoder(), tables }, context.value); + } + catch (error) { + gpu.discard(); + throw error; + } + }; + + const holdings: Holdings = { + removed: false, released: false, partials: null, up: up ?? ordered, directories: kernel?.directories ?? [], indexes: indexLookups, gpu: null, prefix: `${leafNumber}:`, + afterSync: partialCount > 0 ? new WeakRef(sizeSlots) : null, remote: null, + }; + + const conditionIs = (resources?.is ?? []).map(key => idOf(state, key)); + const conditionNot = (resources?.not ?? []).map(key => idOf(state, key)); + /** Whether a condition's value is neither `null` nor `false`. */ + const holding = (id: number): boolean => { + const { storage, row } = resourceCell(state, state.keys[id]); + const value = readCell(storage, row); + return value !== null && value !== false; + }; + const holds = (): boolean => conditionIs.every(holding) && !conditionNot.some(holding); + const conditioned = conditionIs.length + conditionNot.length > 0; + /** Whether an index of `indexFilters` changed its entries since `since`: refreshed before its tier, so current. */ + const indexesChangedSince = (since: number): boolean => { + for (let k = 0; k < indexFilters.length; k++) if (indexFilters[k].changed > since) return true; + return false; + }; + /** Whether its only change filters are indexes, so a run in which none changed passes no row. */ + const indexesOnly = indexFiltered && addedIds.length + changedIds.length + resourceChanged.length === 0 && up === null; + + /** A GPU kernel that could never run on the GPU is rejected in every world, with or without one. */ + const load = async (): Promise => { + if (kernel === null) return; + sizeSlots(); + await kernel.load(); + if (declaration.kernel !== "gpu") return; + const generated = kernel.gpu(); + if (typeof generated === "string") return failPlacement(`can't run on the GPU: ${generated}`); + const upWords = up === null ? 0 : 3 + (kernel.description.upColumns?.length ?? 0); + if (1 + kernel.setup.sources.length + upWords + generated.scalars.length > recordWords) failPlacement(`can't run on the GPU: it uses more than ${recordWords - 1} columns and values`); + const gpu = state.gpu; + if (placement !== "gpu" || gpu === null) return; + const { device } = gpu; + const compute = await device.createComputePipelineAsync({ + layout: device.createPipelineLayout({ bindGroupLayouts: [gpu.computeLayout] }), + compute: { module: device.createShaderModule({ code: generated.code }), entryPoint: "main" }, + }); + pipeline = { compute, kernel: generated }; + }; + + const start = (input: unknown, workers: Workers | null, index: number): readonly Chunk[] => { + ran = false; + // Removed during the run it's in: its body isn't called again. + if (holdings.removed) return noChunks; + // A system whose conditions don't hold is skipped, successfully, and keeps its `lastRun`. + if (conditioned && !holds()) return noChunks; + const indexesChanged = indexFiltered && indexesChangedSince(lastRun); + // One filtered only by indexes, none of which changed, is skipped as when a condition fails. + if (indexesOnly && !indexesChanged) return noChunks; + const tick = ++state.tick; + for (let k = 0; k < writes.length; k++) state.written[writes[k]] = tick; + lookupSince.value = lastRun; + const args = ownInput(input); + slotsUsed = 0; + // The last run's spawns were committed, or dropped with its sync point. + if (spawns) for (let b = 0; b < spawnBatches.length; b++) spawnBatches[b].count = unstaged[b] = 0; + const chunks = runChunks; + // Truncated to one rather than emptied: emptying an array drops its backing store, which the push would + // allocate again. Setting the length costs a run more than the check, so it's set only when it differs. + if (chunks.length !== 1) chunks.length = 1; + // The first buffer, as `nextChunk` gives it, with nothing staged yet to stage. + used = 1; + holder.buffer = owned[0]; + chunks[0] = ownedChunks[0]; + try { + const resourcesChanged = indexesChanged || (resourceChanged.length > 0 && resourcesChangedSince(lastRun)); + /** -1 passes every row. */ + const since = !filtered || resourcesChanged ? -1 : lastRun; + context.bind(); + if (declaration.input !== undefined) context.value.input = args; + if (indexed && kernel !== null) indexParameters = kernel.refreshIndexes(lastRun); + if (placement === "render") { + refreshMatches(); + runRender(tick); + return chunks; + } + if (query === undefined) { + ran = since < 0; + if (ran) system.body(context.value); + return chunks; + } + // Only resource and index filters, none of which changed: no row passes, unless its parent changed. + if (since >= 0 && addedIds.length + changedIds.length === 0 && up === null) return chunks; + ran = true; + refreshMatches(); + if (target !== undefined) { + runTarget(Reflect.get(Object(args), target)); + return chunks; + } + const values = kernel === null || kernel.description.resources.length === 0 ? noValues : kernel.description.resources.map(key => { + const { storage, row } = resourceCell(state, key); + return readCell(storage, row); + }); + if (placement === "gpu") { + if (up !== null) runUpOnGpu(values, args, tick); + else runOnGpu(values, args, tick); + return chunks; + } + if (up !== null) { + const parallel = runUp(since, values, args, tick, workers, index); + if (parallel !== null) chunks.push(parallel); + return chunks; + } + if (ordered !== null) { + runOrdered(ordered, since, values, args, tick); + return chunks; + } + for (const archetype of matches) { + if (archetype.count === 0) continue; + const from = sinceFor(archetype, since); + if (kernel === null) runBody(archetype, from); + else if (workers !== null && archetype.count >= minimumFor(workers)) { + chunks.push(...postChunks(workers, index, archetype, from, values, args, tick)); + chunks.push(nextChunk()); + } + else runKernelLocally(kernel, archetype, from, values, args, tick); + } + return chunks; + } + catch (error) { + // Chunks already posted still finish before the sync point. + chunks.push({ commands: emptyBuffer, error }); + return chunks; + } + finally { + if (spawns) { + stageSpawns(); + endBlock(block, state.unused); + } + lastRun = tick; + } + }; + + const finish = (failed: boolean): readonly Float64Array[] | null => { + if (!ran || failed || targets.length === 0) return null; + if (kernel === null) return context.reduced; + targets.forEach((target, t) => folded[t]?.fill(identityOf(target.operator))); + partialLeaves.forEach(({ target, leaf }, k) => { + const operator = targets[target]?.operator ?? "+"; + const values = folded[target]; + if (values === undefined) return; + for (let slot = 0; slot < slotsUsed; slot++) values[leaf] = combine(operator, values[leaf] ?? NaN, slots[slot * partialCount + k] ?? NaN); + }); + return folded; + }; + + const setup = (index: number): KernelSetup | null => { + if (kernel === null) return null; + const compiled = kernel.compiled(); + return { + index, + url: kernel.description.url, + ...kernel.setup, + // A worker's commands and spawns leave other components' starts to the world, so only data crosses. + starts: kernel.setup.starts.map((start, k) => state.layouts[kernel.setup.keys[k]]?.kind === "linear" ? start : null), + // An `up` kernel is passed only the rows that pass its filters. + added: up === null ? addedIds : [], + changed: up === null ? changedIds : [], + reduces: partialCount > 0, + wasm: compiled === null || kernel.wasm === null ? null : { module: compiled.module, scalars: kernel.wasm.scalars, imports: kernel.wasm.imports }, + }; + }; + + return { + kernel, + placement, + touches, + writes, + looks, + resourceWrites: resourceWritesOf(state, declaration), + matched: () => { + refreshMatches(); + return matches; + }, + indexes: indexLookups.uses, + load, + setup, + start, + targets, + finish, + afterSync: partialCount > 0 ? sizeSlots : null, + holdings, + remote: null, + joined: null, + lastRun: () => lastRun, + restart: declaration.observer !== true || !holdsNothing(holdings) ? null : since => { + lastRun = since; + holdings.removed = false; + holdings.released = false; + }, + }; +}; + +const nothing: readonly never[] = []; + +/** What runs read in place of a runner whose system was removed during them: it runs nothing. */ +export const removedRunner: Runner = { + kernel: null, + placement: "cpu", + touches: nothing, + writes: nothing, + looks: nothing, + resourceWrites: nothing, + matched: () => nothing, + indexes: nothing, + load: async () => {}, + setup: () => null, + start: () => nothing, + targets: nothing, + finish: () => null, + afterSync: null, + holdings: { ...holdingNothing(), removed: true, released: true }, + remote: null, + joined: null, + lastRun: () => 0, + restart: null, +}; diff --git a/packages/ecs/src/implementation/world/schedule.test.ts b/packages/ecs/src/implementation/world/schedule.test.ts new file mode 100644 index 000000000..aa64015ce --- /dev/null +++ b/packages/ecs/src/implementation/world/schedule.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Composite } from "../../interface/system/composite.js"; +import type { Declaration } from "../../interface/system/declaration.js"; +import type { Definition } from "../../interface/registry/index.js"; +import type { Leaf } from "../../interface/system/leaf.js"; +import { scheduleOf } from "../../interface/registry/schedule.js"; + +const value = Symbol("scheduleTest/value"); +const other = Symbol("scheduleTest/other"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [other]: { type: number }; + } +} + +Component.register(value, { type: { type: "number" } }); +Component.register(other, { type: { type: "number" } }); + +/** Systems built as values, not registered, so each test's are its own. */ +const leaf = (declaration: Declaration & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, body: () => {} }); +const composite = (declaration: Composite["declaration"] & { readonly name: string }): Composite => ({ kind: "composite", name: declaration.name, declaration }); + +const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(tier => tier.map(leaf => leaf.name)); + +describe("scheduleOf", () => { + it("tiers by longest ordering path, keeping flatten order within a tier", () => { + const a = leaf({ name: "scheduleTest/a" }); + const b = leaf({ name: "scheduleTest/b", after: [a] }); + const c = leaf({ name: "scheduleTest/c", before: [b] }); + const d = leaf({ name: "scheduleTest/d" }); + const root = composite({ name: "scheduleTest/root", systems: [b, d, c, a] }); + expect(names(root)).toEqual([["scheduleTest/d", "scheduleTest/c", "scheduleTest/a"], ["scheduleTest/b"]]); + }); + + it("applies a composite's constraints to every member, and resolves composites to their members", () => { + const a = leaf({ name: "scheduleTest/a" }); + const b = leaf({ name: "scheduleTest/b" }); + const d = leaf({ name: "scheduleTest/d" }); + const group = composite({ name: "scheduleTest/group", systems: [a, b], after: [d] }); + const c = leaf({ name: "scheduleTest/c", after: [group] }); + expect(names(composite({ name: "scheduleTest/root", systems: [c, group, d] }))) + .toEqual([["scheduleTest/d"], ["scheduleTest/a", "scheduleTest/b"], ["scheduleTest/c"]]); + }); + + it("pulls during pairs into one tier unless an ordering path separates them", () => { + const a = leaf({ name: "scheduleTest/a" }); + const b = leaf({ name: "scheduleTest/b", after: [a] }); + const c = leaf({ name: "scheduleTest/c", during: [b] }); + const d = leaf({ name: "scheduleTest/d", after: [c] }); + expect(names(composite({ name: "scheduleTest/root", systems: [a, b, c, d] }))) + .toEqual([["scheduleTest/a"], ["scheduleTest/b", "scheduleTest/c"], ["scheduleTest/d"]]); + }); + + it("orders a system during another between what that system follows and what follows it", () => { + const update = leaf({ name: "scheduleTest/update" }); + const render = leaf({ name: "scheduleTest/render", after: [update] }); + const writer = leaf({ name: "scheduleTest/writer", components: { write: [value] }, during: [update] }); + const reader = leaf({ name: "scheduleTest/reader", components: { read: [value] }, during: [render] }); + expect(names(composite({ name: "scheduleTest/root", systems: [reader, writer, update, render] }))) + .toEqual([["scheduleTest/writer", "scheduleTest/update"], ["scheduleTest/reader", "scheduleTest/render"]]); + }); + + it("takes the order of a system during a system that is itself during another", () => { + const update = leaf({ name: "scheduleTest/update" }); + const render = leaf({ name: "scheduleTest/render", after: [update] }); + const physics = leaf({ name: "scheduleTest/physics", during: [update] }); + const writer = leaf({ name: "scheduleTest/writer", components: { write: [value] }, during: [physics] }); + const reader = leaf({ name: "scheduleTest/reader", components: { read: [value] }, during: [render] }); + expect(names(composite({ name: "scheduleTest/root", systems: [reader, writer, physics, update, render] }))) + .toEqual([["scheduleTest/writer", "scheduleTest/physics", "scheduleTest/update"], ["scheduleTest/reader", "scheduleTest/render"]]); + }); + + it("orders a system during a composite around the composite, not between its members", () => { + const start = leaf({ name: "scheduleTest/start", components: { write: [other] } }); + const a = leaf({ name: "scheduleTest/a" }); + const b = leaf({ name: "scheduleTest/b", after: [a] }); + const group = composite({ name: "scheduleTest/group", systems: [a, b], after: [start] }); + const end = leaf({ name: "scheduleTest/end", components: { read: [value] }, after: [group] }); + const c = leaf({ name: "scheduleTest/c", components: { read: [other], write: [value] }, during: [group] }); + expect(names(composite({ name: "scheduleTest/root", systems: [c, start, group, end] }))) + .toEqual([["scheduleTest/start"], ["scheduleTest/c", "scheduleTest/a"], ["scheduleTest/b"], ["scheduleTest/end"]]); + }); + + it("still throws on conflicting systems during one system, and on a during that contradicts before or after", () => { + const update = leaf({ name: "scheduleTest/update" }); + const render = leaf({ name: "scheduleTest/render", after: [update] }); + const a = leaf({ name: "scheduleTest/a", components: { write: [value] }, during: [update] }); + const b = leaf({ name: "scheduleTest/b", components: { write: [value] }, during: [update] }); + expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [update, render, a, b] }))).toThrow('"scheduleTest/a" and "scheduleTest/b"'); + const late = leaf({ name: "scheduleTest/late", during: [update], after: [render] }); + expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [update, render, late] }))).toThrow("Ordering cycle"); + }); + + it("ignores constraints naming systems outside the one being scheduled", () => { + const a = leaf({ name: "scheduleTest/a", after: [leaf({ name: "scheduleTest/d" })] }); + expect(names(composite({ name: "scheduleTest/root", systems: [a] }))).toEqual([["scheduleTest/a"]]); + }); + + it("throws on a cycle", () => { + const a = leaf({ name: "scheduleTest/a" }); + const b = leaf({ name: "scheduleTest/b", after: [a], before: [a] }); + expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, b] }))).toThrow("Ordering cycle"); + }); + + it("throws on an unordered write conflict, but not when ordered or disjoint", () => { + const a = leaf({ name: "scheduleTest/a", components: { write: [value] } }); + const b = leaf({ name: "scheduleTest/b", components: { read: [value] } }); + const c = leaf({ name: "scheduleTest/c", components: { write: [other] }, during: [a] }); + expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, b] }))).toThrow('"scheduleTest/a" and "scheduleTest/b"'); + const ordered = leaf({ ...b.declaration, name: b.name, after: [a] }); + expect(names(composite({ name: "scheduleTest/root", systems: [a, ordered, c] }))) + .toEqual([["scheduleTest/a", "scheduleTest/c"], ["scheduleTest/b"]]); + }); + + it("schedules two systems of one name as two, and one listed twice as one", () => { + const a = leaf({ name: "scheduleTest/a", components: { write: [value] } }); + const copy = leaf({ name: "scheduleTest/a", components: { write: [value] }, after: [a] }); + expect(scheduleOf(composite({ name: "scheduleTest/root", systems: [copy, a, a] })).tiers).toEqual([[a], [copy]]); + expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, leaf({ name: "scheduleTest/a", components: { write: [value] } })] }))).toThrow('Systems "scheduleTest/a" and "scheduleTest/a" both access'); + }); +}); diff --git a/packages/ecs/src/implementation/world/semantics.test.ts b/packages/ecs/src/implementation/world/semantics.test.ts new file mode 100644 index 000000000..14c0d86b4 --- /dev/null +++ b/packages/ecs/src/implementation/world/semantics.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { System } from "../../interface/system/system.js"; +import { count, readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; +import { runReported } from "./reports.test-helper.js"; + +type Vec2 = readonly [number, number]; +const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const satisfies Schema; +const position = Symbol("semanticsTest/position"); +const target = Symbol("semanticsTest/target"); +const score = Symbol("semanticsTest/score"); +const tag = Symbol("semanticsTest/tag"); +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number] }; + [target]: { type: Entity }; + [score]: { type: number }; + [tag]: { type: true }; + } +} +Component.register(position, { type: vec2 }); +Component.register(target, { type: Entity.schema }); +Component.register(score, { type: { type: "number" } }); +Component.register(tag, { type: { const: true } }); + +const seed = System.create({ name: "semanticsTest/seed" }, ({ commands }) => { + // @ts-expect-error untyped callers can store any number as an entity + commands.spawn([position, target, score])({ [position]: [5, 6], [target]: -1, [score]: 0 }); + // @ts-expect-error untyped callers can store any number as an entity + commands.spawn([position, target, score])({ [position]: [7, 8], [target]: Number.NaN, [score]: 0 }); +}); +const chase = System.create({ + name: "semanticsTest/chase", + kernel: "cpu", + components: { read: [target], write: [score] }, + lookup: { targets: { read: [position] } }, +}, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + if (other === null) { + row[score] = -1; + return; + } + const [x] = other[position]; + row[score] = x; +}); + +const run = async (wasm: boolean) => { + const world = createWorld({ [workers]: 0 }, { wasm }); + await world.run(seed); + await world.run(chase); + const result = await readEach(world, await select(world, { required: [score] }), score); + await world.dispose(); + return result; +}; + +describe("semantics", () => { + it("a throwing sync point doesn't poison the next one or another world", async () => { + let poison = false; + const spawn = System.create({ name: "semanticsTest/seed/2" }, ({ commands }) => { + commands.spawn([score])({ [score]: 1 }); + commands.spawn([score])({ [score]: poison ? Symbol("x") as unknown as number : 2 }); + }); + const a = createWorld({ [workers]: 0 }); + const b = createWorld({ [workers]: 0 }); + await b.run(spawn); + poison = true; + expect((await runReported(a, spawn)).failures).toHaveLength(1); + poison = false; + const errors: string[] = []; + await b.run(spawn).catch(e => errors.push("b:" + String(e))); + await a.run(spawn).catch(e => errors.push("a:" + String(e))); + expect(errors).toEqual([]); + // The value that can't be written throws when its spawn is issued, so only the spawn before it applies. + expect([await count(a, { required: [score] }), await count(b, { required: [score] })]).toEqual([3, 4]); + }); + it("lookup of -1 / NaN entity agrees between JS and WASM", async () => { + const js = await run(false); + const wasm = await run(true); + expect(wasm).toEqual(js); + }); + + it("re-adding an existing tag keeps its added tick", async () => { + const seen: number[] = []; + let entity = Entity.none; + const spawn = System.create({ name: "semanticsTest/seed/3" }, ({ commands }) => { entity = commands.spawn([tag, score])({ [tag]: true, [score]: 0 }); }); + const retag = System.create({ name: "semanticsTest/retag" }, ({ commands }) => { commands.add(entity, tag, true); }); + const watch = System.create({ name: "semanticsTest/watch", components: { has: [score], added: [tag] } }, row => { seen.push(row.entity); }); + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(watch); + await world.run(retag); + await world.run(watch); + await world.dispose(); + expect(seen.length).toBe(1); + }); +}); diff --git a/packages/ecs/src/implementation/world/share.test.ts b/packages/ecs/src/implementation/world/share.test.ts new file mode 100644 index 000000000..b12fdf01d --- /dev/null +++ b/packages/ecs/src/implementation/world/share.test.ts @@ -0,0 +1,17 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld, join } from "./create.js"; + +afterEach(() => { vi.unstubAllGlobals(); }); + +describe("world.share", () => { + it("throws for a world and a join without cross-origin isolation, where memory can't be shared", async () => { + vi.stubGlobal("crossOriginIsolated", false); + const world = createWorld({ [workers]: 0 }); + const { port1, port2 } = new MessageChannel(); + expect(() => world.share(port1)).toThrow("Without cross-origin isolation memory can't be shared, so no thread can join."); + expect(() => join(port2)).toThrow("Without cross-origin isolation memory can't be shared, so no thread can join."); + port1.close(); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/sharing.ts b/packages/ecs/src/implementation/world/sharing.ts new file mode 100644 index 000000000..519fa0a88 --- /dev/null +++ b/packages/ecs/src/implementation/world/sharing.ts @@ -0,0 +1,588 @@ +import type { World } from '../../interface/world/world.js'; +import type { Failure } from '../../interface/report/failure.js'; +import { definitionOf, idOfKey, indexDefinitionOf, itemOfId, keyOfId, labelOfItem, requireDefinition, type Definition } from '../../interface/registry/index.js'; +import type { Views } from '../../interface/registry/layout.js'; +import { failureOf } from './failures.js'; +import { + completedWord, done, controlBytes, inputByMessage, inputLayoutOf, inputMoved, platformLocks, postedWord, protocolName, rejected, runSlots, slotRun, + signalWord, unreadWord, slotInput, slotNumber, slotPayload, slotStatus, slotViews, slotWords, slotsWord, threadChannel, unposted, + type ObserverDeclaration, type ObserverIndex, type ThreadChannel, type ToJoined, type ToWorld, +} from './joining.js'; +import { createObserverTarget, droppedObserver, endTarget, joinedRun, type ObserverTarget } from './joined-observer.js'; +import type { Leaf } from '../../interface/system/leaf.js'; +import { Index } from '../../interface/indexes/index-type.js'; +import { System } from '../../interface/system/system.js'; +import { createMoveLog, freeMoveLog, type MoveLog } from './move-log.js'; +import { lineWords } from './protocol.js'; +import { claimThreads, releaseThreads } from './remote.js'; +import { createTaskChannel, type TaskChannel } from './remote-systems.js'; +import { endStops } from './stop-lists.js'; +import { dropRecords, keepRecords } from './strings.js'; +import { idOf, type Key, type State } from './state.js'; +import { heldInput, type HeldInput } from './transfers.js'; + +/** What sharing needs of the world. */ +export type SharingHost = { + readonly state: State; + /** Runs a system as `world.run` does. */ + readonly run: (definition: Definition, input: unknown) => Promise; + /** A failure for the next run's report. */ + readonly defer: (failure: Failure) => void; + /** + * Why a joined thread that imported the systems of ids `imported` can't claim `threads`: a system it can't run + * there, or a finalizer on one; null when none. + */ + readonly crossing: (threads: readonly string[], imported: ReadonlySet) => string | null; + /** Places systems again, once claims were made or released. */ + readonly placed: () => void; + /** Runs a task between runs, in the world's queue. */ + readonly between: (task: () => void) => void; + /** A joined thread's own observer systems, as the world declares them: added to ticks, removed, or forgotten. */ + readonly observer: { + readonly add: (system: Definition) => void; + readonly remove: (system: Definition) => void; + readonly drop: (system: Definition) => void; + }; +}; + +/** A number a joined thread gave a key or system, with its id, and what the id names here, or null for nothing. */ +type Named = { readonly id: string; readonly value: Key | Definition | null }; + +/** + * A thread's progress as the world shows it: `"joining"` until it has joined, `"joined"` once the threads it claims + * are its own, then `"gone"` once it disconnected, died or couldn't join, and its claims are released. + */ +export type ThreadProgress = { readonly status: "joining" | "joined" | "gone"; readonly claims: readonly string[] }; + +/** A thread that joined, or is joining, through a port this world shared. */ +type Joined = { + readonly channel: ThreadChannel; + status: "joining" | "joined" | "gone"; + claims: readonly string[]; + /** Its control words and run slots, in the memory; null until it joined, and once it left. */ + control: Int32Array | null; + views: readonly Views[]; + /** Whether it waits without blocking, so `Atomics.notify` wakes it. */ + waits: boolean; + readonly names: Map; + /** The next run to start, the last one completed, and those finished out of order. */ + next: number; + completed: number; + readonly finished: Set; + /** Inputs posted for runs not yet started. */ + readonly inputs: Map; + /** For runs not yet started whose input moved to the joined thread their system runs on: that thread, once it has it. */ + readonly moved: Map; + /** The joined threads it has a channel to, to move inputs through. */ + readonly peers: Set; + /** Whether a wake is queued for it. */ + woken: boolean; + /** Ends the world's request for its lock. */ + readonly abort: AbortController; + /** Its log, once the objects of the threads it claims are its own; and its number, which storage flags name. */ + log: MoveLog | null; + readonly number: number; + /** The ids of the systems it imported, which those placed on it must be among. */ + imported: ReadonlySet; + /** Its task channel, once systems placed on the threads it claims run there. */ + tasks: TaskChannel | null; + /** Its end for observer systems, once it joined. */ + observer: ObserverTarget | null; + /** Its own observer systems, as the world declares them, by its numbers for them. */ + readonly proxies: Map; +}; + +const hasWaitAsync = (): boolean => typeof Atomics.waitAsync === "function"; + +const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error); + +/** + * The world's side of joined threads: `share` records a thread joining, then its handshake records its claims, which a + * thread claimed twice can't join; `progress` shows each one's, in the order they were shared. Runs come through its + * slots in shared memory, in sequence, naming systems by its numbers, and complete through its completed word, with a + * number in its slot; its observer systems are posted to it after each sync point, with one notify + * per thread a turn. + */ +export const createSharing = (host: SharingHost) => { + const { state } = host; + const memory = state.memory; + const threads = new Set(); + /** Logical threads by the joined thread claiming them. */ + const claimed = new Map(); + let closed = false; + const waking: Joined[] = []; + /** Each joined thread's block of control words, as allocated. */ + const controlBlocks = new Map(); + /** Joined threads' numbers, never reused, so a thread that died never reads another's flags as its own; those not gone, by number. */ + let numbered = 0; + const byNumber = new Map(); + + const post = (thread: Joined, message: ToJoined, transfer?: readonly object[]): void => thread.channel.post(message, transfer); + + /** Every shared thread's progress, by its number, and those observing it. */ + let progress: readonly ThreadProgress[] = []; + const watchers = new Set<(value: readonly ThreadProgress[]) => void>(); + const show = (thread: Joined, status: ThreadProgress["status"]): void => { + const next = [...progress]; + next[thread.number - 1] = { status, claims: thread.claims }; + progress = next; + for (const notify of [...watchers]) notify(progress); + }; + const observeProgress = (notify: (value: readonly ThreadProgress[]) => void): (() => void) => { + watchers.add(notify); + notify(progress); + return () => { watchers.delete(notify); }; + }; + + const wake = (thread: Joined): void => { + const { control } = thread; + if (control === null) return; + // Marked unread before the signal moves: a thread that read the signal past this wake then sees the word set, + // or cleared it after, so it's never left set for a wake the thread read, which would keep later wakes from + // notifying a thread waiting for them. A thread that hasn't read the last wake yet reads this one with it. + const read = Atomics.exchange(control, unreadWord, 1) === 0; + Atomics.add(control, signalWord, 1); + if (!read) return; + Atomics.notify(control, signalWord); + if (!thread.waits) post(thread, { protocol: protocolName, kind: "wake" }); + }; + + /** Wakes every thread with something new once, after the posts or completions of this turn. */ + const flush = (): void => { + for (const thread of waking) { + thread.woken = false; + wake(thread); + } + waking.length = 0; + }; + + const wakeSoon = (thread: Joined): void => { + if (thread.woken) return; + thread.woken = true; + if (waking.push(thread) === 1) queueMicrotask(flush); + }; + + /** + * A run finished: its status and its number, the run's or 0 for a rejection, in its slot, a rejection posted, and + * completed advanced through those in order. + */ + const finish = (thread: Joined, sequence: number, status: number, run: number, message: ToJoined | null): void => { + const { control } = thread; + if (control === null || thread.status === "gone") return; + if (message !== null) post(thread, message); + const base = slotsWord + ((sequence - 1) % runSlots) * slotWords; + Atomics.store(control, base + slotRun, run); + Atomics.store(control, base + slotStatus, status); + thread.finished.add(sequence); + let completed = thread.completed; + while (thread.finished.delete(completed + 1)) completed++; + if (completed === thread.completed) return; + thread.completed = completed; + Atomics.store(control, completedWord, completed); + wakeSoon(thread); + }; + + const reject = (thread: Joined, sequence: number, text: string): void => + finish(thread, sequence, rejected, 0, { protocol: protocolName, kind: "rejected", sequence, message: text }); + + /** Run `sequence` of `thread`, whose input moved to `holder`, finished: if no task there took it, it's released there. */ + const released = (holder: Joined | null, thread: Joined, sequence: number, held: HeldInput | null = null): void => { + if (holder !== null && holder.status === "joined" && held?.taken !== true) post(holder, { protocol: protocolName, kind: "release", from: thread.number, sequence }); + }; + + /** The joined thread holding a run's moved input, its claims made, by the number its slot names; null once it's gone. */ + const holderOf = (thread: Joined, number: number): Joined | null => { + const holder = number === thread.number ? thread : byNumber.get(number); + return holder !== undefined && holder.status === "joined" && holder.tasks !== null ? holder : null; + }; + + /** + * Starts the runs posted in its slots, in order, through `run`, so they land in call order with the world's own; a + * run whose system's id or input hasn't arrived yet waits, with the runs after it. + */ + const drain = (thread: Joined): void => { + const { control } = thread; + if (control === null || thread.status !== "joined") return; + const posted = Atomics.load(control, postedWord); + while (thread.next <= posted) { + const sequence = thread.next; + const slot = (sequence - 1) % runSlots; + const base = slotsWord + slot * slotWords; + const number = control[base + slotNumber]; + if (number === unposted) { + thread.next++; + reject(thread, sequence, "The run's input couldn't be posted."); + continue; + } + const named = thread.names.get(number); + if (named === undefined) return; + const bytes = control[base + slotInput]; + if (bytes === inputByMessage && !thread.inputs.has(sequence)) return; + // A moved input waits until the thread it moved to has it; this thread's own has it already. + const holder = bytes === inputMoved ? holderOf(thread, control[base + slotPayload]) : null; + if (holder !== null && holder !== thread && thread.moved.get(sequence) !== holder) return; + thread.next++; + const input = bytes === inputByMessage ? thread.inputs.get(sequence) : undefined; + if (bytes === inputByMessage) thread.inputs.delete(sequence); + else if (bytes === inputMoved) { + thread.moved.delete(sequence); + if (holder === null) { + reject(thread, sequence, "The run's input moved to a joined thread that's gone."); + continue; + } + } + const definition = typeof named.value === "object" && named.value !== null ? named.value : null; + // A run of the thread's own observer system names it, so its post runs it there for this run. + const own = definition !== null && definition.kind !== "composite" && thread.proxies.get(number) === definition; + if (definition === null) { + reject(thread, sequence, `System "${named.id}" isn't one this world imported, so it can't run here.`); + released(holder, thread, sequence); + continue; + } + const layout = bytes >= 0 && definition.kind !== "composite" ? inputLayoutOf(definition, definition.declaration.input) : null; + const held = holder !== null && holder.tasks !== null ? heldInput(thread.number, sequence, holder.tasks) : null; + const value = own ? joinedRun(sequence) : held ?? (layout !== null && layout.kind === "linear" ? layout.read(thread.views[slot], 0) : input); + let ran: Promise; + try { + ran = host.run(definition, value); + } + catch (error) { + reject(thread, sequence, messageOf(error)); + released(holder, thread, sequence); + continue; + } + const settled = ran.then( + run => finish(thread, sequence, done, run, null), + error => reject(thread, sequence, messageOf(error)), + ); + if (held !== null) void settled.then(() => released(holder, thread, sequence, held)); + } + }; + + /** Starts what runs it posts as they're posted, waiting on its posted word without blocking. */ + const listen = async (thread: Joined): Promise => { + const { control } = thread; + while (control !== null && thread.status === "joined" && !closed) { + const seen = Atomics.load(control, postedWord); + drain(thread); + const waited = Atomics.waitAsync(control, postedWord, seen); + if (waited.async) await waited.value; + } + }; + + const keyOf = (thread: Joined, number: number): Key => { + const value = thread.names.get(number)?.value; + if (typeof value !== "symbol") throw new Error(`A joined thread names "${thread.names.get(number)?.id ?? number}", which isn't a key this world imported.`); + return value; + }; + + /** + * Gives a thread that just joined a channel to each joined thread it may move a run's input to, or that may move + * one to it: every pair one of which fills objects, its claims made. A thread whose claims are pending is paired + * once they're made. + */ + const pair = (thread: Joined): void => { + if (typeof globalThis.MessageChannel !== "function") return; + const fills = thread.tasks !== null; + for (const other of threads) { + if (other === thread || other.status !== "joined" || other.peers.has(thread)) continue; + const otherFills = other.tasks !== null; + if (other.claims.length > 0 && !otherFills) continue; + if (!fills && !otherFills) continue; + const { port1, port2 } = new MessageChannel(); + thread.peers.add(other); + other.peers.add(thread); + post(thread, { protocol: protocolName, kind: "peer", thread: other.number, threads: other.claims, port: port1 }, [port1]); + post(other, { protocol: protocolName, kind: "peer", thread: thread.number, threads: thread.claims, port: port2 }, [port2]); + } + }; + + /** Tells a thread a component whose objects it fills, by its id. */ + const announce = (thread: Joined, component: number): void => { + const key = state.keys[component]; + post(thread, { protocol: protocolName, kind: "component", component, id: typeof key === "symbol" ? idOfKey(key) ?? null : null }); + }; + + /** + * Its claims checked against those already made, and against systems and finalizers on them; its control words + * placed, and its status `"joined"`; or refused. With claims, its objects become its own between runs, then it's + * told. + */ + const refuse = (thread: Joined, message: string): void => { + post(thread, { protocol: protocolName, kind: "refused", message }); + gone(thread); + }; + + const join = (thread: Joined, threads: readonly string[], waits: boolean, lock: string | null, imported: readonly string[]): void => { + if (thread.status !== "joining") return; + const locks = platformLocks(); + // Granted once the thread no longer holds it: it left, or ended. + if (lock !== null && locks !== null) locks.request(lock, { signal: thread.abort.signal }, () => gone(thread)).catch(() => {}); + thread.imported = new Set(imported); + const twice = threads.find((name, at) => name === "world" || threads.indexOf(name) < at || claimed.has(name)); + const placed = twice === undefined ? host.crossing(threads, thread.imported) : null; + if (twice !== undefined || placed !== null) return refuse(thread, placed ?? (twice === "world" ? `A joined thread can't claim "world", the world's own thread.` : `Thread "${twice}" is claimed twice.`)); + for (const name of threads) claimed.set(name, thread); + thread.claims = threads; + thread.waits = waits; + const offset = memory.allocate(controlBytes + lineWords * 4); + const start = Math.ceil(offset / (lineWords * 4)) * lineWords * 4; + thread.control = new Int32Array(memory.memory.buffer, start, controlBytes / 4).fill(0); + controlBlocks.set(thread, offset); + thread.views = slotViews(memory.memory.buffer, start); + thread.status = "joined"; + thread.observer = createObserverTarget(thread.number, threads.length === 0 ? `#${thread.number}` : threads.join(`" and "`), message => post(thread, message), () => wakeSoon(thread)); + const joined = (): void => post(thread, { + protocol: protocolName, kind: "joined", memory: memory.memory, control: start, waits: hasWaitAsync(), log: thread.log?.block ?? null, + tasks: thread.tasks?.block ?? null, thread: thread.number, + }); + if (threads.length === 0) { + joined(); + pair(thread); + show(thread, "joined"); + } + else host.between(() => { + if (thread.status !== "joined") return; + // Systems registered since it asked to join are checked too. + const crossing = host.crossing(threads, thread.imported); + if (crossing !== null) return refuse(thread, crossing); + const log = createMoveLog(memory, thread.number, state.starts, component => announce(thread, component)); + thread.log = log; + claimThreads(state, log, threads); + const tasks = createTaskChannel(state, log, message => post(thread, message), () => wake(thread), threads.join(`" and "`), number => { + const value = thread.names.get(number)?.value; + return typeof value === "symbol" ? idOf(state, value) : -1; + }); + thread.tasks = tasks; + for (const name of threads) state.threadTasks.set(name, tasks); + const { observer } = thread; + if (observer !== null) { + observer.log = log; + for (const name of threads) state.observer.targets.set(name, observer); + } + state.channels.push(tasks); + joined(); + pair(thread); + host.placed(); + show(thread, "joined"); + }); + if (hasWaitAsync()) void listen(thread); + }; + /** Returns a thread's control words to the memory, once it left: a thread that died may still be writing them. */ + const free = (thread: Joined): void => { + const block = controlBlocks.get(thread); + if (block === undefined) return; + memory.free(block, controlBytes + lineWords * 4); + controlBlocks.delete(thread); + thread.control = null; + }; + + /** + * Disconnected, dead or refused: its status `"gone"`, its claims released, and its objects back here between runs. + * Its log is freed once it `left`: a thread that died may still be reading it. + */ + const gone = (thread: Joined, left = false): void => { + if (thread.status === "gone") return; + const { control, log, tasks } = thread; + thread.status = "gone"; + threads.delete(thread); + byNumber.delete(thread.number); + // With no thread left to read them, strings' records are let go between runs, unless another shares first. + if (threads.size === 0 && state.strings !== null) host.between(() => { if (threads.size === 0 && state.strings !== null) dropRecords(state.strings, state.archetypes); }); + // Its channels close; runs whose input moved to it can't run, and reject. + for (const peer of thread.peers) { + peer.peers.delete(thread); + if (peer.status === "joined") post(peer, { protocol: protocolName, kind: "unpeer", thread: thread.number }); + } + thread.peers.clear(); + if (!closed) for (const other of threads) drain(other); + for (const name of thread.claims) if (claimed.get(name) === thread) claimed.delete(name); + // A task in flight settles now, its systems skipped with a "thread" failure; they're placed here between runs. + tasks?.end(); + // Its observer systems' posts settle, its own forgotten, those on the threads it claimed placed here again. + const { observer } = thread; + if (observer !== null) { + for (const [name, target] of [...state.observer.targets]) if (target === observer) state.observer.targets.delete(name); + endTarget(state, observer, left); + for (const proxy of thread.proxies.values()) host.observer.drop(proxy); + thread.proxies.clear(); + } + if (log !== null) host.between(() => { + // Indexes its finalizers held return, and stops it never acknowledged are reported. + if (tasks !== null) state.unplaced.push(...endStops(tasks.stops, state.holds, state.entities.released, thread.claims.join(`" and "`))); + releaseThreads(state, log); + if (tasks !== null) { + for (const [name, channel] of [...state.threadTasks]) if (channel === tasks) state.threadTasks.delete(name); + state.channels.splice(state.channels.indexOf(tasks), 1); + host.placed(); + if (left) tasks.free(); + } + if (left) freeMoveLog(log); + show(thread, "gone"); + }); + else if (!closed) show(thread, "gone"); + if (control !== null) Atomics.notify(control, postedWord); + thread.abort.abort(); + }; + + const receive = (thread: Joined, message: ToWorld): void => { + switch (message.kind) { + case "join": return join(thread, message.threads, message.waits, message.lock, message.imported); + case "completed": return thread.tasks?.completed(message); + case "unplaced": return thread.tasks?.unplaced(message.indexes); + case "finalized": { + const { tasks } = thread; + if (tasks === null) return; + for (const [index, error] of message.errors) { + const finalizer = tasks.finalizerAt(index); + if (finalizer !== undefined) host.defer(failureOf("finalizer", labelOfItem(finalizer), Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); + } + return; + } + case "name": { + const found = message.item ? itemOfId(message.id) : keyOfId(message.id); + const value = found === undefined ? null : typeof found === "symbol" ? found : definitionOf(found) ?? null; + thread.names.set(message.number, { id: message.id, value }); + return drain(thread); + } + case "input": { + thread.inputs.set(message.sequence, message.input); + return drain(thread); + } + case "received": { + const from = byNumber.get(message.from); + // The run's thread is gone: its input is of no use. + if (from === undefined) return post(thread, { protocol: protocolName, kind: "release", from: message.from, sequence: message.sequence }); + from.moved.set(message.sequence, thread); + return drain(from); + } + case "observer": return declare(thread, message.number, message.name, message.declaration, message.added); + case "observer add": + case "observer remove": + case "observer drop": { + const proxy = thread.proxies.get(message.number); + if (proxy === undefined) return; + if (message.kind === "observer add") return host.observer.add(proxy); + if (message.kind === "observer remove") return host.observer.remove(proxy); + thread.proxies.delete(message.number); + thread.names.delete(message.number); + return host.observer.drop(proxy); + } + case "observer failed": { + for (const [index, error] of message.errors) { + const placed = thread.observer?.placed[index]; + if (placed !== undefined) host.defer(failureOf("system", placed.label, Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); + } + return; + } + case "observer dropped": return thread.observer === null ? undefined : droppedObserver(state, thread.observer, message.indexes); + case "observer again": return state.observer.again?.(); + case "wake": return drain(thread); + case "leave": { + gone(thread, true); + return free(thread); + } + } + }; + + /** Lets a thread join through `port`: `"joining"`, with no claims. */ + const share = (port: Parameters[0]): void => { + const thread: Joined = { + channel: threadChannel(port), status: "joining", claims: [], control: null, views: [], waits: true, + names: new Map(), next: 1, completed: 0, finished: new Set(), inputs: new Map(), moved: new Map(), peers: new Set(), woken: false, + abort: new AbortController(), log: null, number: ++numbered, imported: new Set(), tasks: null, observer: null, proxies: new Map(), + }; + threads.add(thread); + byNumber.set(thread.number, thread); + // From now on the thread may read strings, which it reads out of their records. + if (state.strings !== null) keepRecords(state.strings, state.archetypes); + show(thread, "joining"); + thread.channel.onMessage(message => { if (thread.status !== "gone" || message.kind === "leave") receive(thread, message); }); + thread.channel.onClose(() => gone(thread)); + }; + + /** + * A joined thread's own observer system, declared by its number for it: the world's value standing for it, which + * runs on that thread, with the keys and indexes it names by id; a run of that number runs it. One the world can't + * declare, naming a key or index it never imported, is a failure in the next run's report. + */ + /** Indexes made for joined threads' observer systems from declarations without ids, by what they declare, kept while the world lives. */ + const madeIndexes = new Map>(); + /** An index like one a joined thread declared without an id: one per declaration, for every thread. */ + const madeIndex = (thread: Joined, declared: ObserverIndex): ReturnType => { + const keys = (numbers: readonly number[]): Key[] => numbers.map(each => keyOf(thread, each)); + const key = keys(declared.key); + const has = keys(declared.has); + const not = keys(declared.not); + const ids = (list: readonly Key[]): string => list.map(each => idOf(state, each)).join(","); + const identity = `${ids(key)}|${ids(has)}|${ids(not)}|${declared.sorted ? "s" : ""}${declared.unique ? "u" : ""}`; + let index = madeIndexes.get(identity); + if (index === undefined) { + // `Index.create` checks the declaration as the joined thread's was checked. + index = indexDefinitionOf(Reflect.apply(Index.create, undefined, [{ name: declared.name, key, sorted: declared.sorted, unique: declared.unique, components: { has, not } }])); + madeIndexes.set(identity, index); + } + return index; + }; + + const declare = (thread: Joined, number: number, name: string, declared: ObserverDeclaration, added: boolean): void => { + const target = thread.observer; + if (target === null) return; + let proxy: Leaf; + try { + const keys = (numbers: readonly number[]): Key[] => numbers.map(each => keyOf(thread, each)); + /** The lists given, of keys, leaving out empty ones but a lookup's `changed`, which chooses its row's form. */ + const listed = (entries: readonly (readonly [string, readonly number[] | null])[]): { [field: string]: Key[] } => { + const result: { [field: string]: Key[] } = {}; + for (const [field, numbers] of entries) if (numbers !== null && (numbers.length > 0 || field === "changed")) result[field] = keys(numbers); + return result; + }; + const lookup: { [name: string]: object } = {}; + for (const each of declared.lookup) { + if (each.index === null) { + lookup[each.name] = listed([["read", each.read], ["has", each.has], ["not", each.not], ["changed", each.changed]]); + continue; + } + const index = typeof each.index === "string" ? indexDefinitionOf(itemOfId(each.index)) : madeIndex(thread, each.index); + if (index === undefined) throw new Error(`A joined thread's observer system looks up through index "${String(each.index)}", which this world never imported.`); + lookup[each.name] = { index, ...listed([["read", each.read], ["changed", each.changed]]) }; + } + const { components, resources } = declared; + // Unnamed, so other systems' labels stay as they are; its own is the thread's. + const declaration = { + observer: true, + ...components === null ? {} : { components: listed([["read", components.read], ["has", components.has], ["not", components.not], ["added", components.added], ["changed", components.changed]]) }, + ...resources === null ? {} : { resources: listed([["read", resources.read], ["changed", resources.changed], ["is", resources.is], ["not", resources.not]]) }, + ...declared.lookup.length === 0 ? {} : { lookup }, + }; + // `System.create` checks the declaration as the joined thread's was checked; its body runs only there. + const created = requireDefinition(Reflect.apply(System.create, undefined, [declaration, () => {}])); + if (created.kind === "composite") return; + proxy = created; + } + catch (error) { + host.defer(failureOf("system", name, error)); + // Its runs reject. + thread.names.set(number, { id: `#observer:${number}`, value: null }); + drain(thread); + return; + } + state.observer.proxies.set(proxy, { target, local: number, label: name }); + thread.proxies.set(number, proxy); + thread.names.set(number, { id: `#observer:${number}`, value: proxy }); + if (added) host.observer.add(proxy); + drain(thread); + }; + + /** At the world's dispose: joined threads hear of it. */ + const close = (): void => { + closed = true; + for (const thread of [...threads]) { + post(thread, { protocol: protocolName, kind: "disposed" }); + gone(thread); + thread.channel.close(); + } + }; + + return { share, close, progress: observeProgress }; +}; diff --git a/packages/ecs/src/implementation/world/staging.ts b/packages/ecs/src/implementation/world/staging.ts new file mode 100644 index 000000000..168915e11 --- /dev/null +++ b/packages/ecs/src/implementation/world/staging.ts @@ -0,0 +1,196 @@ +import type { Entity } from '../../interface/entity/entity.js'; +import { viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; +import { writeF32, writeF32Lanes, writeF64, writeI32, writeOf, writeU32, type Archetype } from './archetype.js'; +import type { Reservations } from './entities.js'; + +// Spawn staging that needs no world: batches, their writers and generated spawners, shared by the main thread and workers. + +/** One component of a spawn batch: its values in the target storage's layout, so committing is a block copy. */ +export type Column = { + readonly id: number; + readonly layout: Layout; + readonly write: number; + readonly unitStride: number; + readonly unitOffset: number; + readonly lanes: number; + readonly stride: number; + views: Views; + readonly values: unknown[]; + /** Where a joined thread fills its objects, so a spawn here gives it only its start or `null`: why another fails. */ + remote: string | null; +}; + +/** + * Spawns of one set of components, written as they're issued into private columns and copied into their archetype at + * the sync point. Nothing touches archetype storage during a run, and spawned values are read once, while fresh. + */ +export type SpawnBatch = { + /** Sorted component ids. */ + readonly ids: readonly number[]; + /** The archetype of `ids`, found at the first sync point the batch commits at. */ + archetype: Archetype | null; + readonly columns: readonly Column[]; + entities: Float64Array; + count: number; + capacity: number; +}; + +/** Writes one value into row `row` of a column, specialized to the column's layout when the spawner is made. */ +export type Writer = (row: number, value: unknown) => void; + +/** Spawns of a batch's components with their values by position, each `undefined` its start. */ +export type Positional = (...values: unknown[]) => Entity; + +const initialCapacity = 64; + +const columnOf = (layout: Layout, id: number, capacity: number): Column => { + const stride = layout.kind === "linear" ? layout.stride : 0; + return { id, layout, ...writeOf(layout), stride, views: viewsOf(new ArrayBuffer(capacity * stride), 0, capacity * stride), values: [], remote: null }; +}; + +/** An empty batch of spawns of exactly these components, by their layouts. */ +export const createBatch = (layouts: readonly Layout[], ids: readonly number[]): SpawnBatch => { + const sorted = [...ids].sort((a, b) => a - b); + return { ids: sorted, archetype: null, columns: sorted.map(id => columnOf(layouts[id], id, initialCapacity)), entities: new Float64Array(initialCapacity), count: 0, capacity: initialCapacity }; +}; + +const grow = (batch: SpawnBatch): void => { + const capacity = batch.capacity * 2; + for (const column of batch.columns) { + const views = viewsOf(new ArrayBuffer(capacity * column.stride), 0, capacity * column.stride); + views.u8.set(column.views.u8.subarray(0, batch.count * column.stride)); + column.views = views; + } + const entities = new Float64Array(capacity); + entities.set(batch.entities.subarray(0, batch.count)); + batch.entities = entities; + batch.capacity = capacity; +}; + +/** + * A column's writer: numbers and f32 tuples straight into its view, the rest through `writeColumn`; `undefined` writes + * `start`, the component's start. + */ +export const writerOf = (column: Column, start: unknown): Writer => { + const { unitStride, unitOffset, lanes } = column; + const general: Writer = column.layout.kind === "object" || column.layout.kind === "string" + ? (row, value) => { + if (column.remote !== null && value !== undefined && value !== null && value !== start) throw new Error(column.remote); + writeColumn(column, row, value === undefined ? start : value); + } + : (row, value) => writeColumn(column, row, value === undefined ? start : value); + switch (column.write) { + case writeF32: return (row, value) => { + if (typeof value !== "number") return general(row, value); + column.views.f32[row * unitStride + unitOffset] = value; + }; + case writeU32: return (row, value) => { + if (typeof value !== "number") return general(row, value); + column.views.u32[row * unitStride + unitOffset] = value; + }; + case writeF32Lanes: return (row, value) => { + if (!Array.isArray(value)) return general(row, value); + const { f32 } = column.views; + const index = row * unitStride + unitOffset; + for (let lane = 0; lane < lanes; lane++) f32[index + lane] = Number(value[lane]); + }; + default: return general; + } +}; + +/** Writes one value as the sync point would: numbers and f32 lane tuples straight into the view, the rest through the layout. */ +const writeColumn = (column: Column, row: number, value: unknown): void => { + const { views } = column; + const index = row * column.unitStride + column.unitOffset; + switch (column.write) { + case writeF32: if (typeof value === "number") return void (views.f32[index] = value); break; + case writeU32: if (typeof value === "number") return void (views.u32[index] = value); break; + case writeI32: if (typeof value === "number") return void (views.i32[index] = value); break; + case writeF64: if (typeof value === "number") return void (views.f64[index] = value); break; + case writeF32Lanes: + if (Array.isArray(value)) { + for (let lane = 0; lane < column.lanes; lane++) views.f32[index + lane] = Number(value[lane]); + return; + } + } + const { layout } = column; + if (layout.kind === "linear") layout.write(views, row, value); + // A string is written into its record as the batch commits, on the world's thread. + else if (layout.kind === "object" || layout.kind === "string") column.values[row] = value; +}; + +/** The row the batch's next spawn writes, growing it first if it's full. */ +export const openRow = (batch: SpawnBatch): number => { + if (batch.count === batch.capacity) grow(batch); + return batch.count; +}; + +/** Claims the open row for `entity`, once all its values are written. */ +export const claim = (batch: SpawnBatch, entity: number): void => { + batch.entities[batch.count] = entity; + batch.count++; +}; + +/** Whether code can be generated here; a content security policy without `'unsafe-eval'` forbids it. */ +let generating = true; + +/** Statements storing value expression `value` into column `k`: numbers and f32 tuples inline, anything else through its writer. */ +const store = (column: Column, k: number, value: string): string => { + const at = `row * ${column.unitStride} + ${column.unitOffset}`; + switch (column.write) { + case writeF32: + case writeU32: + return `v = ${value}; if (typeof v === "number") c${k}.views.${column.write === writeF32 ? "f32" : "u32"}[${at}] = v; else w${k}(row, v);`; + case writeF32Lanes: { + const lanes = Array.from({ length: column.lanes }, (_, lane) => `f[at + ${lane}] = Number(v[${lane}]);`).join(" "); + return `v = ${value}; if (Array.isArray(v)) { const f = c${k}.views.f32; const at = ${at}; ${lanes} } else w${k}(row, v);`; + } + default: + return `w${k}(row, ${value});`; + } +}; + +/** + * A function generated to spawn into `batch`, taking `parameters`: one straight-line store per column, of the value + * expression `values[k]` gives, which may name key `k${k}` of `keys`, so each store sees one layout and each read one + * object shape, then reserves its entity with `take(entities)`. Only numbers are written into the code: keys, columns + * and writers are its arguments. Null where code can't be generated. + */ +export const generated = (entities: E, take: (entities: E) => Entity, batch: SpawnBatch, parameters: readonly string[], keys: readonly unknown[], columns: readonly Column[], writers: readonly Writer[], values: (k: number) => string): F | null => { + if (!generating) return null; + const body = [ + `return (${parameters.join(", ")}) => {`, "const row = openRow(batch);", "let v;", + ...columns.map((column, k) => store(column, k, values(k))), + "const entity = take(entities);", "claim(batch, entity);", "return entity;", "};", + ].join("\n"); + try { + const factory = new Function("batch", "entities", "openRow", "claim", "take", ...columns.flatMap((_, k) => [`k${k}`, `c${k}`, `w${k}`]), body); + // The factory returns the function its body above makes. + const made: F = factory(batch, entities, openRow, claim, take, ...columns.flatMap((_, k) => [keys[k], columns[k], writers[k]])); + return made; + } + catch { + generating = false; + return null; + } +}; + +/** + * Spawns into `batch` taking one value per component of `order`, a list of the batch's component ids, each + * `undefined` its `starts` entry at the same position, reserving each entity through `take`: generated, else a loop + * over the writers. A constant's column holds nothing, so nothing is written for it. + */ +export const positionalSpawner = (entities: E, take: (entities: E) => Entity, batch: SpawnBatch, order: readonly number[], starts: readonly unknown[]): Positional => { + const columns = batch.columns.filter(column => column.layout.kind !== "constant"); + const at = columns.map(column => order.indexOf(column.id)); + const writers = columns.map((column, k) => writerOf(column, starts[at[k]])); + const made = generated(entities, take, batch, order.map((_, k) => `v${k}`), [], columns, writers, k => `v${at[k]}`); + if (made !== null) return made; + return (...values) => { + const row = openRow(batch); + for (let k = 0; k < writers.length; k++) writers[k](row, values[at[k]]); + const entity = take(entities); + claim(batch, entity); + return entity; + }; +}; diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts new file mode 100644 index 000000000..0171bfa25 --- /dev/null +++ b/packages/ecs/src/implementation/world/state.ts @@ -0,0 +1,521 @@ +import { createHolds, type Holds } from './stop-lists.js'; +import type { Entity } from '../../interface/entity/entity.js'; +import { declarationOf, firstKeyOf, kindOf, labelOf } from '../../interface/registry/index.js'; +import { createArchetype, startTicks, type Archetype, type Storage } from './archetype.js'; +import type { SpawnBatch } from './staging.js'; +import type { CommandBuffer } from './buffer.js'; +import type { ListShape, Recent } from './lists.js'; +import type { Directory } from './directories.js'; +import type { GpuEngine } from './gpu.js'; +import type { Queued } from './synchronize.js'; +import type { IndexChanges } from './cascade.js'; +import { createEntities, type Entities, type Reserved } from './entities.js'; +import type { FinalizerRunner, Thrown } from './finalizers.js'; +import { createPlan, type Plan } from './plan.js'; +import { createStructure, owner, restratify, weak, type Structure } from './structure.js'; +import { layoutOf, objectLayout, type Layout } from '../../interface/registry/layout.js'; +import { createMemory, type Memory } from './memory.js'; +import { createStrings, type StringStore } from './strings.js'; +import { createLayoutDirectory, type LayoutDirectory } from './layout-directory.js'; +import { createJournal, type Journal } from './journal.js'; +import type { Row } from './rows.js'; +import type { MoveLog } from './move-log.js'; +import type { TaskChannel } from './remote-systems.js'; +import type { Tail } from './tails.js'; +import { createObserverState, type ObserverState } from './joined-observer.js'; +import { claimComponent } from './remote.js'; +import type { IndexDefinition } from '../../interface/registry/index.js'; +import type { WorldIndex } from './indexes.js'; +import type { Failure } from '../../interface/report/failure.js'; + +export type Key = string | symbol; + +/** Everything a world stores. Components and resources share ids; resources live on `resourceEntity`. */ +/** What a run ran, gathered as its tiers run: how many systems, whether any ran on `main`, GPU work, joined threads by their logs. */ +export type Ran = { systems: number; main: boolean; gpu: boolean; readonly joined: Set }; + +type RanRunner = { readonly placement: string; readonly remote: { readonly channel: { readonly log: MoveLog } } | null }; + +/** What a tier's runners run, worked out once per list of runners and `main`'s claim: joined threads' logs, and flags. */ +type TierRan = { readonly main: MoveLog | undefined; readonly here: boolean; readonly onMain: boolean; readonly gpu: boolean; readonly logs: readonly MoveLog[] }; +const tiersRan = new WeakMap(); + +const tierRanOf = (runners: readonly RanRunner[], main: MoveLog | undefined): TierRan => { + const known = tiersRan.get(runners); + if (known !== undefined && known.main === main) return known; + let here = false; + let onMain = false; + let gpu = false; + const logs: MoveLog[] = []; + for (let i = 0; i < runners.length; i++) { + const { remote, placement } = runners[i]; + if (remote !== null) { + if (!logs.includes(remote.channel.log)) logs.push(remote.channel.log); + if (remote.channel.log === main) onMain = true; + } + else here = true; + if (placement !== "cpu") gpu = true; + } + const tier = { main, here, onMain: onMain || (here && main === undefined), gpu, logs }; + tiersRan.set(runners, tier); + return tier; +}; + +/** Counts a tier's runners into `ran`: one on a joined thread by its log, one here on `main` unless a joined thread claims it. */ +export const countRan = (state: State, runners: readonly RanRunner[]): void => { + const { ran } = state; + ran.systems += runners.length; + const tier = tierRanOf(runners, state.claims.get("main")); + if (tier.onMain) ran.main = true; + if (tier.gpu) ran.gpu = true; + for (let k = 0; k < tier.logs.length; k++) ran.joined.add(tier.logs[k]); +}; + +/** Starts gathering what a run ran. */ +export const startRan = (ran: Ran): void => { + ran.systems = 0; + ran.main = false; + ran.gpu = false; + if (ran.joined.size !== 0) ran.joined.clear(); +}; + +export type State = { + readonly memory: Memory; + readonly entities: Entities; + readonly archetypes: Archetype[]; + /** Archetypes created or bumped since the layout directory's last flush. */ + readonly bumped: Archetype[]; + /** How many times any archetype's arrays were replaced: rows bound to them are current while it stays the same. */ + readonly replaced: { count: number }; + /** Where other threads find archetypes and the entity table in the shared memory; null without shared memory. */ + readonly directory: LayoutDirectory | null; + readonly archetypeByKey: Map; + readonly ids: Map; + readonly keys: Key[]; + /** By component: how its values are stored here, strings as objects where there's no shared memory. */ + readonly layouts: Layout[]; + /** The records strings' columns name, in shared memory; null without it, where strings are held as objects. */ + readonly strings: StringStore | null; + /** Whether each component is derived, so only rules add and remove it. */ + readonly derived: boolean[]; + /** Whether each component is declared `transfer`, so a value whose command drops is released; and whether any is. */ + readonly transfers: boolean[]; + transferring: boolean; + readonly resources: Set; + /** The newest tick handed out. Systems and sync points each take the next one. */ + tick: number; + /** By component id: the tick of the last sync point that wrote a reference into it. */ + readonly referencesWritten: number[]; + /** The entities sync points spawned, despawned, moved or wrote references of, for `up` systems and indexes. */ + readonly journal: Journal; + /** Command buffers for the next sync point, in merge order. */ + readonly queued: Queued[]; + /** Buffers that workers' command ring segments drain into, the first `drained` in use until the sync point's end. */ + readonly drains: CommandBuffer[]; + drained: number; + resourceEntity: number; + /** + * Components some registered system filters on with `changed`, or a finalizer's index follows; only their kernel + * stamps are kept. By component id, how many do: each system while registered, each finalizer index's watch for the + * world's life (`observe`). + */ + readonly observed: Set; + readonly observedCounts: number[]; + /** Workers' kernel indexes, by kernel module url, so they're bounded by code. */ + readonly kernelIndexes: Map; + /** + * Components some registered system filters on with `added` or `changed`, a finalizer's index follows, or a GPU + * system holds copies of; only they keep ticks. + */ + readonly ticked: Set; + /** + * Components some system registered since the world began holds GPU copies of. Kernels keep their chunk stamps, + * so a GPU copy uploads only the chunks CPU writes changed. + */ + readonly resident: Set; + /** Main-thread spawns since the last sync point, by component set. */ + readonly batches: SpawnBatch[]; + readonly batchByKey: Map; + /** + * Lists of components commands named, by their sorted ids; archetypes by their program-wide index, and the last + * eight plain lists, which find them first. + */ + readonly lists: Map; + readonly byIndex: ListShape[]; + /** Every list shape, by its index. */ + readonly shapes: ListShape[]; + readonly recent: Recent[]; + nextRecent: number; + /** By component id: whether commands may name it, once a command has. */ + readonly commandable: boolean[]; + /** By component id: what it starts at when a spawn or add leaves it out. */ + readonly starts: unknown[]; + /** Lookup directories for WASM kernels, kept up to date at sync points. */ + readonly directories: Set; + /** Workers the world may run kernels on; with none, kernels run on the main thread. */ + workers: number; + /** + * Called at the end of each sync point, to size what depends on archetypes, such as reduction partials: each + * registered runner's hook, held weakly, so the runner lives only as long as its system. + */ + readonly afterSync: Set void>>; + /** Accessor rows for main-thread systems, by the component ids they read and write. */ + readonly rows: Map; + /** Whether kernels with a program run as WASM, and as SIMD where they can; tests turn these off to compare with JS. */ + readonly wasm: boolean; + readonly simd: boolean; + /** The sync point's plan, kept from planning to applying. */ + readonly plan: Plan; + /** A sync point between planning and applying: its queued buffers, reservations and tick. */ + syncing: { + readonly queued: readonly Queued[]; + readonly reservations: Reserved; + /** The entity table's length once they were made. */ + readonly reserved: number; + readonly tick: number; + /** By batch target archetype id, the first row its batches added. */ + readonly born: Map; + readonly index: IndexChanges | null; + /** Whether rules or `within` declarations can change archetypes. */ + readonly deriving: boolean; + /** Applies only its queued buffers, for registration's own changes. */ + readonly isolated: boolean; + /** Reservations returned unused, which no spawn consumes. */ + readonly unreserved: number; + } | null; + /** Reservation positions blocks left unused since the last sync point, as start and end pairs. */ + readonly unused: number[]; + /** Reservations made during the last sync point's finalizers, which the next one accounts for. */ + carried: Reserved | null; + /** While finalizers run: by batch target archetype id, the first row its batches added this sync point, which lookups don't see. */ + born: Map | null; + readonly structure: Structure; + /** The program's finalizers this world has activated, in their order. */ + readonly finalizers: FinalizerRunner[]; + /** The program's rules and finalizers active here, which apply from the first sync point after the world meets their components. */ + readonly activated: Set; + /** The program's version when the world last activated, and whether it has met a component since. */ + activation: { version: number; met: boolean }; + /** Resource values given at creation, in place of their defaults. */ + readonly initial: Map; + /** The world's GPU, where render systems and GPU kernels run. */ + gpu: GpuEngine | null; + /** With a GPU: archetypes the current sync point changed, whose GPU copies are then out of date. */ + touched: Set | null; + /** By component id: the log of the joined thread filling its objects (`remote.ts`). */ + readonly remote: (MoveLog | undefined)[]; + /** Logical threads joined threads claim, by their logs; the logs; and whether there are any. */ + readonly claims: Map; + readonly logs: MoveLog[]; + claimed: boolean; + /** What the run in progress ran, for its report: systems, whether any ran on `main`, GPU work, joined threads' logs. */ + readonly ran: Ran; + /** Report components, whose archetypes only queries that require a component see. */ + readonly meta: Set; + /** Whether a joined thread reads the layout directory's counts: one holds claims, or has observer systems placed. */ + counting: boolean; + /** Observer systems on joined threads (`joined-observer.ts`). */ + readonly observer: ObserverState; + /** While a joined thread may read them: archetypes whose count changed since the layout directory's last flush. */ + readonly counted: Archetype[]; + /** By logical thread a joined thread claims, its task channel, which systems placed there run through; and every channel. */ + readonly threadTasks: Map; + readonly channels: TaskChannel[]; + /** Moves on whenever systems are placed again, as claims are made or released. */ + placement: number; + /** Moves on whenever a registered system's runner is replaced, so runs read runners again only then. */ + runnersVersion: number; + /** + * Failures of finalizers the next sync point reports: those on claimed threads that can't run there, which don't + * activate, with every one found; and stops a thread that's gone never finalized. + */ + readonly unplaced: Thrown[]; + readonly unplacedSeen: Set; + /** + * Indexes of entities despawned with stops of joined threads' finalizers, held apart from `released` until those + * threads acknowledge them. + */ + readonly holds: Holds; + /** Tails posted whose reads a later writer must still copy, or wait for (`tails.ts`). */ + readonly tails: Tail[]; + /** The indexes some registered system or finalizer uses, by declaration (`indexes.ts`). */ + readonly indexes: Map; + /** By component id: the tick of the last system run that wrote it, which indexes read to skip ticks no write moved. */ + readonly written: number[]; + /** The tick of the last sync point that wrote a command's value into a row. */ + valuesWritten: number; + /** Unique indexes' conflicts refreshes found, for the report of the run in progress. */ + readonly indexFailures: Failure[]; + /** Indexes built in phases so far, and of them across the pool, for tests of how builds run. */ + phasedBuilds: number; + pooledBuilds: number; + /** Components finalizers' indexes follow since the last run, whose ticks and kernels' stamps start before the next. */ + readonly watchesPending: number[]; +}; + +export const createState = (shared?: boolean, wasm = true, simd = true, gpu = false, initial: Map = new Map()): State => { + const memory = createMemory(shared); + return { + memory, + entities: createEntities(memory), + archetypes: [], + bumped: [], + replaced: { count: 0 }, + directory: createLayoutDirectory(memory), + archetypeByKey: new Map(), + ids: new Map(), + keys: [], + layouts: [], + strings: memory.shared ? createStrings(memory) : null, + derived: [], + transfers: [], + transferring: false, + resources: new Set(), + tick: 0, + queued: [], + drains: [], + drained: 0, + resourceEntity: -1, + observed: new Set(), + observedCounts: [], + kernelIndexes: new Map(), + referencesWritten: [], + journal: createJournal(), + ticked: new Set(), + resident: new Set(), + batches: [], + batchByKey: new Map(), + unused: [], + lists: new Map(), + byIndex: [], + shapes: [], + recent: [], + nextRecent: 0, + commandable: [], + starts: [], + directories: new Set(), workers: 0, afterSync: new Set(), + rows: new Map(), + wasm, + simd, + plan: createPlan(), + syncing: null, + carried: null, + born: null, + structure: createStructure(), + finalizers: [], + activated: new Set(), + activation: { version: -1, met: true }, + initial, + gpu: null, + touched: gpu ? new Set() : null, + remote: [], + claims: new Map(), + logs: [], + claimed: false, + ran: { systems: 0, main: false, gpu: false, joined: new Set() }, + meta: new Set(), + counting: false, + observer: createObserverState(), + counted: [], + threadTasks: new Map(), + channels: [], + placement: 0, + runnersVersion: 0, + unplaced: [], + unplacedSeen: new Set(), + holds: createHolds(), + tails: [], + indexes: new Map(), + written: [], + valuesWritten: 0, + indexFailures: [], + phasedBuilds: 0, + pooledBuilds: 0, + watchesPending: [], + }; +}; + +/** Counts one more system or index watching a component's changes, so kernels keep its stamps. */ +export const observe = (state: State, id: number): void => { + state.observedCounts[id] = (state.observedCounts[id] ?? 0) + 1; + state.observed.add(id); +}; + +/** Counts one fewer; kernels that muted its stamps before keep them until they load again for another reason. */ +export const unobserve = (state: State, id: number): void => { + const count = (state.observedCounts[id] ?? 0) - 1; + state.observedCounts[id] = count; + if (count <= 0) state.observed.delete(id); +}; + +/** The id of a component or resource the world has met, or undefined. */ +export const metIdOf = (state: State, key: Key): number | undefined => state.ids.get(key) ?? state.ids.get(firstKeyOf(key)); + +/** Components whose hosts are being resolved, to report a cycle of `within` declarations. */ +const resolving = new Set(); + +/** The id of a component or resource. Throws if it has no registered schema. */ +export const idOf = (state: State, key: Key): number => { + const existing = state.ids.get(key); + if (existing !== undefined) return existing; + // Symbols registered under one id are one component, by the first of them. + const first = firstKeyOf(key); + if (first !== key) { + const id = idOf(state, first); + state.ids.set(key, id); + return id; + } + const declaration = declarationOf(key); + if (declaration === undefined) throw new Error(`"${labelOf(key)}" has no registered schema; import the module that registers it.`); + const { structure } = state; + // A host gets its id first, so stratifying with the new edge can name it; a cycle throws before anything is + // recorded. + const within = declaration.within; + let host = -1; + if (typeof within === "symbol") { + if (resolving.has(key)) throw new Error(`Components are within each other in a cycle: ${[...resolving].map(labelOf).sort().join(", ")}.`); + resolving.add(key); + try { + host = idOf(state, within); + } + finally { + resolving.delete(key); + } + } + const id = state.keys.length; + if (host >= 0) { + const hosts = [...structure.hosts]; + hosts[id] = host; + restratify(structure, structure.rules, hosts, other => labelOf(other === id ? key : state.keys[other])); + } + state.ids.set(key, id); + state.keys.push(key); + const declared = layoutOf(declaration.type); + // Only other threads need strings in shared memory, and there are none without it. + const layout: Layout = declared.kind === "string" && state.strings === null ? objectLayout : declared; + state.layouts.push(layout); + state.starts.push(layout.kind === "constant" ? layout.value : defaultOf(key)); + state.commandable.push(false); + state.written.push(0); + state.derived.push(declaration.derived === true); + state.transfers.push(declaration.transfer === true); + if (declaration.transfer === true) state.transferring = true; + structure.hosts[id] = host; + structure.dependents[id] = []; + if (host >= 0) structure.dependents[host].push(id); + structure.references[id] = declaration.reference === "owner" ? owner : declaration.reference === "weak" ? weak : 0; + if (structure.references[id] !== 0) structure.referenceIds.push(id); + if (kindOf(key) === "resource") state.resources.add(id); + state.activation.met = true; + if (state.claimed) claimComponent(state, id); + return id; +}; + +/** + * What a resource or derived component starts at: its declaration's default or its schema's, or `null` when its type + * allows leaving it out. + */ +export const defaultOf = (key: Key): unknown => { + const declaration = declarationOf(key); + if (declaration === undefined) return null; + if ("default" in declaration) return declaration.default; + const { type } = declaration; + return type !== undefined && "default" in type ? type.default : null; +}; + +/** + * The id of a component a command names. Throws for a key that isn't a symbol, isn't registered, or is a resource, + * for a derived component, which only rules add and remove, and for one only the world writes. + */ +export const commandIdOf = (state: State, key: unknown): number => { + const known = typeof key === "symbol" ? state.ids.get(key) : undefined; + if (known !== undefined && state.commandable[known]) return known; + if (Array.isArray(key)) throw new Error("A command names a list of components where one component's symbol goes."); + if (typeof key !== "symbol") throw new Error(`"${labelOf(key)}" isn't a component: a component's key is a symbol.`); + if (kindOf(key) === "resource") throw new Error(`"${labelOf(key)}" is a resource, not a component.`); + const id = idOf(state, key); + if (state.derived[id]) throw new Error(`"${labelOf(key)}" is derived, so only rules add and remove it.`); + state.commandable[id] = true; + return id; +}; + +/** The archetype for sorted component ids, created if needed. Creation allocates, so it happens only at sync points. */ +export const archetypeOf = (state: State, components: readonly number[]): Archetype => { + const key = components.join(","); + const existing = state.archetypeByKey.get(key); + if (existing !== undefined) return existing; + const archetype = createArchetype(state.memory, state.strings, state.archetypes.length, components, state.layouts, components.some(id => state.resources.has(id)), components.some(id => state.meta.has(id)), state.ticked, state.bumped, state.replaced, state.remote); + state.archetypes.push(archetype); + state.archetypeByKey.set(key, archetype); + return archetype; +}; + +/** Starts keeping ticks for components, in every archetype. Allocates, so it runs only between runs. */ +export const startTicking = (state: State, components: readonly number[]): void => { + for (const id of components) { + if (state.ticked.has(id)) continue; + state.ticked.add(id); + for (const archetype of state.archetypes) { + const storage = archetype.storageById[id]; + if (storage !== undefined) startTicks(state.memory, archetype, storage, state.tick); + } + } +}; + +/** + * Marks components as held on the GPU, once they keep ticks. Returns whether any is new, so kernels that muted its + * chunk stamps load again. + */ +export const reside = (state: State, components: readonly number[]): boolean => { + let added = false; + for (const id of components) { + if (state.resident.has(id)) continue; + state.resident.add(id); + added = true; + } + return added; +}; + +/** The archetype with `id` added, through a cached edge. Creating one allocates, so this runs only at sync points. */ +export const withComponent = (state: State, archetype: Archetype, id: number): Archetype => { + if (archetype.storageById[id] !== undefined) return archetype; + let next = archetype.adding[id]; + if (next === undefined) { + next = archetypeOf(state, [...archetype.components, id].sort((a, b) => a - b)); + archetype.adding[id] = next; + } + return next; +}; + +export const withoutComponent = (state: State, archetype: Archetype, id: number): Archetype => { + if (archetype.storageById[id] === undefined) return archetype; + let next = archetype.removing[id]; + if (next === undefined) { + next = archetypeOf(state, archetype.components.filter(component => component !== id)); + archetype.removing[id] = next; + } + return next; +}; + +/** The storages two archetypes share, as source and target pairs, cached per pair. */ +export const movesOf = (from: Archetype, to: Archetype): readonly Storage[] => { + let moves = from.moves[to.id]; + if (moves === undefined) { + moves = to.storages.flatMap(storage => { + const source = from.storageById[storage.component]; + return source === undefined ? [] : [source, storage]; + }); + from.moves[to.id] = moves; + } + return moves; +}; + +/** Own symbol keys whose value isn't `undefined`: string keys are never a component's. */ +export const entriesOf = (values: object): [Key, unknown][] => { + const entries: [Key, unknown][] = []; + for (const key of Object.getOwnPropertySymbols(values)) { + const value: unknown = Reflect.get(values, key); + if (value !== undefined) entries.push([key, value]); + } + return entries; +}; diff --git a/packages/ecs/src/implementation/world/stop-lists.ts b/packages/ecs/src/implementation/world/stop-lists.ts new file mode 100644 index 000000000..32dfebf01 --- /dev/null +++ b/packages/ecs/src/implementation/world/stop-lists.ts @@ -0,0 +1,308 @@ +import type { Finalizer } from '../../interface/finalizer/created.js'; +import type { Memory } from './memory.js'; +import type { StringStore } from './strings.js'; +import { logFinalizedWord, logStopsWord, type MoveLog } from './move-log.js'; + +// Stop lists (threads.md, "Finalizers on other threads"): what a joined thread's finalizers need of the entities a sync +// point stops, written by the world's thread before it applies, one block per sync point with stops, which the thread +// reads as it replays that sync and acknowledges by the sync it finalized through. +// +// A block's words: its sync, modulo 2^32; the next block's offset, stored once it's written, 0 until then; its groups; +// its bytes. Then a group per finalizer with stops, in the world's finalizer order: its index on the thread, its stops, +// its bytes; then as columns, each on 8 bytes, every stop's entity (f64), its archetype and row before apply (u32), the +// tag of each object it reads (u32), and the value of each linear struct it reads in that component's layout, or for a +// string, the handle of a copy of its record, freed with the block, since the thread may read it sync points later. + +export const blockSyncWord = 0; +export const blockNextWord = 1; +export const blockGroupsWord = 2; +export const blockBytesWord = 3; +export const blockHeaderBytes = 16; +export const groupIndexWord = 0; +export const groupCountWord = 1; +export const groupBytesWord = 2; +export const groupHeaderBytes = 16; + +const align = (bytes: number): number => (bytes + 7) & ~7; + +/** Where a group of `count` stops keeps its entities, archetypes and rows, past its header. */ +export const entitiesAt = (group: number): number => group + groupHeaderBytes; +export const archetypesAt = (group: number, count: number): number => group + groupHeaderBytes + 8 * count; +export const rowsAt = (group: number, count: number): number => group + groupHeaderBytes + 12 * count; +/** Where a group's tags of its `object`th object read start. */ +export const tagsAt = (group: number, count: number, object: number): number => group + groupHeaderBytes + 16 * count + object * align(4 * count); +/** Where a group's linear values start, after the tags of its `objects` object reads. */ +export const valuesAt = (group: number, count: number, objects: number): number => tagsAt(group, count, objects); +/** Bytes of one linear read's column of `count` values of `stride` bytes. */ +export const columnBytes = (count: number, stride: number): number => align(count * stride); + +/** Bytes of a group of `count` stops of a finalizer reading `objects` objects and linear values of `strides` bytes. */ +const groupBytes = (count: number, objects: number, strides: readonly number[]): number => { + let bytes = valuesAt(0, count, objects); + for (const stride of strides) bytes += columnBytes(count, stride); + return bytes; +}; + +/** One of a joined thread's finalizers, as the world writes its stops. */ +export type StopsOf = { + readonly finalizer: Finalizer; + /** Its index on the thread. */ + readonly index: number; + /** The components of its object reads, then of its linear reads with their strides, in its `read` order. */ + readonly objects: readonly number[]; + readonly linear: readonly number[]; + readonly strides: readonly number[]; + /** By linear read: whether it's a string's handles, whose records the block holds copies of. */ + readonly copies: readonly boolean[]; + /** This sync point's stops, as plan entries, reused by every sync point. */ + readonly entries: number[]; +}; + +/** A block the thread hasn't acknowledged, or the newest it has, which it still reads the next block's offset from. */ +type Written = { + readonly offset: number; + readonly bytes: number; + readonly sync: number; + /** Indexes of despawned entities, held until the thread acknowledges. */ + readonly held: number[]; + /** Its finalizers and their stops, reported if the thread is gone before it acknowledges. */ + readonly stopped: readonly (readonly [Finalizer, number])[]; + /** The copies of strings' records its stops hold, let go once the thread acknowledges. */ + readonly strings: number[]; + acknowledged: boolean; +}; + +/** The world's end of a joined thread's stop lists. */ +export type StopWriter = { + readonly memory: Memory; + readonly log: MoveLog; + /** The world's records of strings, which stops copy. */ + readonly strings: StringStore | null; + /** Finalizers with stops this sync point, in order, whose block is written once every finalizer ran. */ + readonly staged: StopsOf[]; + readonly written: Written[]; + /** Whether a block was written since the thread was last woken. */ + unwoken: boolean; +}; + +export const createStopWriter = (memory: Memory, log: MoveLog, strings: StringStore | null): StopWriter => ({ memory, log, strings, staged: [], written: [], unwoken: false }); + +/** + * Indexes of entities despawned with stops of joined threads' finalizers: by index, how many threads haven't + * acknowledged the stop, so the sync point kills them without releasing them; and how many are held. + */ +export type Holds = { counts: Int32Array; size: number }; + +export const createHolds = (): Holds => ({ counts: new Int32Array(0), size: 0 }); + +const hold = (holds: Holds, index: number): void => { + if (index >= holds.counts.length) { + const counts = new Int32Array(Math.max(index + 1, 2 * holds.counts.length, 1024)); + counts.set(holds.counts); + holds.counts = counts; + } + if (holds.counts[index]++ === 0) holds.size++; +}; + +/** + * Writes the handles of copies of a string's records at word `at`, for a group's `count` stops at the archetypes and + * rows its words there name, keeping each copy in `copied`. Copying may grow the memory, which leaves `u32` over the + * block as it was: it's shared. + */ +const copyStrings = (strings: StringStore, copied: number[], u32: Uint32Array, at: number, count: number, archetypeWords: number, rowWords: number, id: number, archetypes: StopSource["archetypes"]): void => { + let archetype = -1; + let handles: Uint32Array | undefined; + for (let i = 0; i < count; i++) { + if (u32[archetypeWords + i] !== archetype) { + archetype = u32[archetypeWords + i]; + handles = archetypes[archetype].storageById[id]?.views.u32; + } + if (handles === undefined) continue; + const copy = strings.copy(handles[u32[rowWords + i]]); + copied.push(copy); + u32[at + i] = copy; + } +}; + +/** A storage as stops are copied from it: its tags, or its linear values. */ +type Copied = { readonly tags: Uint32Array; readonly views: { readonly u8: Uint8Array; readonly u32: Uint32Array } }; + +/** What a sync point's stops come from: entries' entities and archetypes before apply, and the entity table's rows. */ +export type StopSource = { + readonly entity: Float64Array; + readonly from: Int32Array; + readonly despawned: Uint8Array; + readonly archetypes: readonly { readonly storageById: readonly (Copied | undefined)[] }[]; + readonly row: Uint32Array; + readonly holds: Holds; +}; + +/** + * Writes the block of a sync point's staged stops, before it applies: entities, archetypes and rows, tags and linear + * values, copied from the rows they're at, a column at a time; despawned entities' indexes held. Links it after the + * last block. + */ +export const writeStops = (writer: StopWriter, sync: number, source: StopSource): void => { + const { staged, memory } = writer; + if (staged.length === 0) return; + let bytes = blockHeaderBytes; + for (const stops of staged) bytes += groupBytes(stops.entries.length, stops.objects.length, stops.strides); + const offset = memory.allocate(bytes); + const buffer = memory.memory.buffer; + const u8 = new Uint8Array(buffer); + const u32 = new Uint32Array(buffer); + const f64 = new Float64Array(buffer); + const held: number[] = []; + const stopped: (readonly [Finalizer, number])[] = []; + const copied: number[] = []; + u32[(offset >> 2) + blockSyncWord] = sync >>> 0; + u32[(offset >> 2) + blockNextWord] = 0; + u32[(offset >> 2) + blockGroupsWord] = staged.length; + u32[(offset >> 2) + blockBytesWord] = bytes; + let group = offset + blockHeaderBytes; + const { entity, from, despawned, archetypes, row: rows, holds } = source; + for (const stops of staged) { + const { entries, objects, linear, strides, copies } = stops; + const count = entries.length; + const size = groupBytes(count, objects.length, strides); + u32[(group >> 2) + groupIndexWord] = stops.index; + u32[(group >> 2) + groupCountWord] = count; + u32[(group >> 2) + groupBytesWord] = size; + const entityWords = entitiesAt(group) >> 3; + const archetypeWords = archetypesAt(group, count) >> 2; + const rowWords = rowsAt(group, count) >> 2; + for (let i = 0; i < count; i++) { + const entry = entries[i]; + const stoppedEntity = entity[entry]; + const index = stoppedEntity >>> 0; + f64[entityWords + i] = stoppedEntity; + u32[archetypeWords + i] = from[entry]; + u32[rowWords + i] = rows[index]; + if (despawned[entry] === 1) { + hold(holds, index); + held.push(index); + } + } + for (let k = 0; k < objects.length; k++) { + const tagWords = tagsAt(group, count, k) >> 2; + const id = objects[k]; + let archetype = -1; + let tags: Uint32Array | undefined; + for (let i = 0; i < count; i++) { + if (u32[archetypeWords + i] !== archetype) { + archetype = u32[archetypeWords + i]; + tags = archetypes[archetype].storageById[id]?.tags; + } + u32[tagWords + i] = tags?.[u32[rowWords + i]] ?? 0; + } + } + let column = valuesAt(group, count, objects.length); + for (let k = 0; k < linear.length; k++) { + const stride = strides[k]; + const id = linear[k]; + const words = (stride & 3) === 0 ? stride >> 2 : 0; + let archetype = -1; + let values: Copied["views"] | undefined; + if (copies[k] && writer.strings !== null) { + copyStrings(writer.strings, copied, u32, column >> 2, count, archetypeWords, rowWords, id, archetypes); + column += columnBytes(count, stride); + continue; + } + for (let i = 0; i < count; i++) { + if (u32[archetypeWords + i] !== archetype) { + archetype = u32[archetypeWords + i]; + values = archetypes[archetype].storageById[id]?.views; + } + if (values === undefined) continue; + const row = u32[rowWords + i]; + if (words > 0) { + const source = values.u32; + const target = (column >> 2) + i * words; + for (let w = 0; w < words; w++) u32[target + w] = source[row * words + w]; + } + else { + const source = values.u8; + const target = column + i * stride; + for (let b = 0; b < stride; b++) u8[target + b] = source[row * stride + b]; + } + } + column += columnBytes(count, stride); + } + stopped.push([stops.finalizer, count]); + group += size; + entries.length = 0; + } + staged.length = 0; + const last = writer.written.at(-1); + // Stored last, so the thread reads the block only once it's whole. + if (last === undefined) Atomics.store(writer.log.header, logStopsWord, offset); + else Atomics.store(u32, (last.offset >> 2) + blockNextWord, offset); + writer.written.push({ offset, bytes, sync: sync >>> 0, held, stopped, strings: copied, acknowledged: false }); + writer.unwoken = true; +}; + +/** Lets go of the copies of strings' records a block's stops hold. */ +const letGo = (writer: StopWriter, block: Written): void => { + const { strings } = writer; + if (strings !== null) for (let k = 0; k < block.strings.length; k++) strings.retire(block.strings[k]); + block.strings.length = 0; +}; + +/** Returns a held index once no thread finalizes it any more: it's reusable after the sync point. */ +const unhold = (holds: Holds, released: number[], held: readonly number[]): void => { + const { counts } = holds; + for (const index of held) { + if (--counts[index] !== 0) continue; + holds.size--; + released.push(index); + } +}; + +/** + * Takes up what the thread acknowledged: blocks through the sync it finalized through return their held indexes, and + * all but the newest of them are freed, since the thread reads the next block's offset from that one. + */ +export const settleStops = (writer: StopWriter, holds: Holds, released: number[]): void => { + const { written, memory } = writer; + if (written.length === 0) return; + const finalized = Atomics.load(writer.log.header, logFinalizedWord) >>> 0; + let newest = -1; + for (let k = 0; k < written.length; k++) { + const block = written[k]; + if (((finalized - block.sync) | 0) < 0) break; + if (!block.acknowledged) { + block.acknowledged = true; + unhold(holds, released, block.held); + letGo(writer, block); + } + newest = k; + } + for (let k = 0; k < newest; k++) memory.free(written[k].offset, written[k].bytes); + if (newest > 0) written.splice(0, newest); +}; + +/** + * Once the thread is gone: every held index returns, and each finalizer with stops it never acknowledged is a `"thread"` + * failure, for the next sync point's report. + */ +export const endStops = (writer: StopWriter, holds: Holds, released: number[], label: string): { readonly finalizer: Finalizer; readonly error: Error; readonly kind: "thread" }[] => { + settleStops(writer, holds, released); + const missed = new Map(); + for (const block of writer.written) { + if (block.acknowledged) continue; + block.acknowledged = true; + unhold(holds, released, block.held); + letGo(writer, block); + for (const [finalizer, count] of block.stopped) missed.set(finalizer, (missed.get(finalizer) ?? 0) + count); + } + return [...missed].map(([finalizer, count]) => ({ + finalizer, kind: "thread", + error: new Error(`Thread "${label}" is gone, so ${count === 1 ? "a stop" : `${count} stops`} of finalizer "${finalizer.name}" didn't run there.`), + })); +}; + +/** Frees every block once the thread left: one that died may still be reading them. */ +export const freeStops = (writer: StopWriter): void => { + for (const block of writer.written) writer.memory.free(block.offset, block.bytes); + writer.written.length = 0; +}; diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts new file mode 100644 index 000000000..c2f5889fc --- /dev/null +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, join } from "./create.js"; +import { readEach, readResource, select } from "./reads.test-helper.js"; +import { fillReports } from "./reports.test-helper.js"; + +const label = Symbol("stringsTest/label"); +const order = Symbol("stringsTest/order"); +const tagged = Symbol("stringsTest/tagged"); +const mode = Symbol("stringsTest/mode"); +const title = Symbol("stringsTest/title"); +const note = Symbol("stringsTest/note"); + +declare module "@adobe/ecs" { + export interface Components { + [label]: { type: string }; + [order]: { type: number }; + [tagged]: { type: true }; + [note]: { type: string | null }; + } + export interface Resources { + [mode]: { type: "edit" | "view" }; + [title]: { type: string }; + } +} + +Component.register(label, { type: { type: "string" } }); +Component.register(order, { type: { type: "number" } }); +Component.register(tagged, { type: { const: true } }); +Component.register(note, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); +Resource.register(mode, { type: { anyOf: [{ const: "edit" }, { const: "view" }], default: "view" } }); +Resource.register(title, { type: { type: "string", default: "" } }); + +/** Text of every kind a string holds: none, letters, astral characters as surrogate pairs, and a long run. */ +const textOf = (n: number): string => n % 4 === 0 ? "" : n % 4 === 1 ? `item ${n}` : n % 4 === 2 ? `✓ ${n} 😀 naïve` : `${"long ".repeat(40)}${n}`; + +const spawn = System.create({ name: "stringsTest/spawn", input: { type: "object", properties: { count: { type: "integer" }, from: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: textOf(input.from + i), [order]: input.from + i }); +}); + +/** `count` rows with labels of 400 code units, whose records are 1024-byte blocks of the arena. */ +const spawnLong = System.create({ name: "stringsTest/spawnLong", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: `${i}`.padStart(400, "·"), [order]: i }); +}); + +/** A run over every row that changes nothing, for its sync point. */ +const pass = System.create({ name: "stringsTest/pass", components: { read: [order] } }, row => { void row[order]; }); + +const rename = System.create({ name: "stringsTest/rename", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], write: [label] } }, (row, { input }) => { + row[label] = `${textOf(row[order])}${input.suffix}`; +}); + +const relabel = System.create({ name: "stringsTest/relabel", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], has: [label] } }, (row, { input, commands }) => { + commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); +}); + +const tag = System.create({ name: "stringsTest/tag", components: { has: [label] } }, (row, { commands }) => commands.add(row.entity, tagged)); +const untag = System.create({ name: "stringsTest/untag", components: { has: [tagged] } }, (row, { commands }) => commands.remove(row.entity, tagged)); +const unlabel = System.create({ name: "stringsTest/unlabel", components: { has: [label] } }, (row, { commands }) => commands.remove(row.entity, label)); +const despawn = System.create({ name: "stringsTest/despawn", components: { has: [order] } }, (row, { commands }) => commands.despawn(row.entity)); +const annotate = System.create({ name: "stringsTest/annotate", components: { read: [order], write: [note] } }, row => { + row[note] = row[order] % 3 === 0 ? null : `note ${row[order]}`; +}); +const unannotate = System.create({ name: "stringsTest/unannotate", components: { write: [note] } }, row => { + row[note] = null; +}); +const noted = System.create({ name: "stringsTest/noted", components: { has: [order], not: [note] } }, (row, { commands }) => commands.add(row.entity, note, null)); +const despawnEven = System.create({ name: "stringsTest/despawnEven", components: { read: [order] } }, (row, { commands }) => { + if (row[order] % 2 === 0) commands.despawn(row.entity); +}); +const labelAgain = System.create({ name: "stringsTest/labelAgain", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], not: [label] } }, (row, { input, commands }) => { + commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); +}); + +const edit = System.create({ name: "stringsTest/edit", input: { type: "object", properties: { title: { type: "string" } } }, resources: { write: [mode, title] } }, ({ input, resources }) => { + resources[mode] = "edit"; + resources[title] = input.title; +}); + +/** Each entity's label, in order. */ +const labels = async (world: ReturnType): Promise<(string | null)[]> => { + const entities: readonly Entity[] = await select(world, { required: [order], order: { [order]: true } }); + return readEach(world, entities, label); +}; + +const arena = async (world: ReturnType): Promise => (await world.tables()).arena; + +/** Once the world's joined threads are as `accept` takes them. */ +const until = (world: ReturnType, accept: (threads: readonly { readonly status: string }[]) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = world.threads(threads => { + if (done || !accept(threads)) return; + done = true; + queueMicrotask(() => stop()); + resolve(); + }); +}); + +describe("strings", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("are written and read back as they were given", async () => { + const world = createWorld({ [workers]: 0, [title]: "Hello 👋" }); + await world.run(spawn, { count: 1000, from: 0 }); + expect(await labels(world)).toEqual(Array.from({ length: 1000 }, (_, i) => textOf(i))); + await world.run(rename, { suffix: "!" }); + expect(await labels(world)).toEqual(Array.from({ length: 1000 }, (_, i) => `${textOf(i)}!`)); + await world.run(relabel, { suffix: "?" }); + expect(await labels(world)).toEqual(Array.from({ length: 1000 }, (_, i) => `${textOf(i)}?`)); + await world.run(noted); + const entities: readonly Entity[] = await select(world, { required: [order], order: { [order]: true } }); + expect(await readEach(world, entities, note)).toEqual(entities.map(() => null)); + await world.run(annotate); + expect(await readEach(world, entities, note)).toEqual(entities.map((_, i) => i % 3 === 0 ? null : `note ${i}`)); + await world.run(unannotate); + expect(await readEach(world, entities, note)).toEqual(entities.map(() => null)); + expect([await readResource(world, mode), await readResource(world, title)]).toEqual(["view", "Hello 👋"]); + await world.run(edit, { title: "Bye" }); + expect([await readResource(world, mode), await readResource(world, title)]).toEqual(["edit", "Bye"]); + await world.dispose(); + }); + it("are kept in shared memory while a joined thread may read them, from its share until it's gone", async () => { + const world = createWorld({ [workers]: 0 }); + // Every run's report is an entity, up to the reports retained, which this holds no more past. + await fillReports(world); + // Records of more than 512 bytes are the arena's own blocks, so the arena shows them freed. + await world.run(spawnLong, { count: 1000 }); + const alone = await arena(world); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: [] }); + await until(world, threads => threads.at(-1)?.status === "joined"); + const shared = await arena(world); + expect(shared - alone).toBeGreaterThan(1000 * 1024); + await remote.dispose(); + await until(world, threads => threads.at(-1)?.status === "gone"); + // Let go between runs, then freed once two sync points have passed. + for (let k = 0; k < 3; k++) await world.run(pass); + expect(await arena(world)).toBeLessThan(alone + 64 * 1024); + expect((await labels(world)).every(text => text?.length === 400)).toBe(true); + await world.dispose(); + }); + it("keep their values as their entities move between archetypes and others leave", async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(spawn, { count: 50, from: 0 }); + await world.run(tag); + await world.run(despawnEven); + await world.run(untag); + expect(await labels(world)).toEqual(Array.from({ length: 25 }, (_, i) => textOf(2 * i + 1))); + await world.dispose(); + }); + it("free a replaced, removed or despawned string's record, so rewriting, removing and spawning again holds no more", async () => { + const world = createWorld({ [workers]: 0 }); + // Records are kept while a joined thread may read them. + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: [] }); + // Every run's report is an entity, up to the reports retained, which this holds no more past. + await fillReports(world); + await world.run(spawn, { count: 200, from: 0 }); + /** What the world holds after `rounds` more, once a first round reached its steady state. */ + const heldAfter = async (rounds: number, round: (n: number) => Promise): Promise => { + // Replaced records are reused once a sync point has passed since, and the tables grow once. + for (let n = 0; n < 4; n++) await round(n); + const steady = await arena(world); + for (let n = 0; n < rounds; n++) await round(n); + return [await arena(world), steady]; + }; + const [rewritten, rewriting] = await heldAfter(20, n => world.run(n % 2 === 0 ? rename : relabel, { suffix: ` ${n % 2}` })); + expect(rewritten).toBeLessThanOrEqual(rewriting); + const [readded, readding] = await heldAfter(6, async n => { + await world.run(unlabel); + await world.run(labelAgain, { suffix: ` ${n % 2}` }); + }); + expect(readded).toBeLessThanOrEqual(readding); + const [respawned, respawning] = await heldAfter(6, async n => { + await world.run(despawn); + await world.run(spawn, { count: 200, from: 0 }); + await world.run(rename, { suffix: ` ${n % 2}` }); + }); + expect(respawned).toBeLessThanOrEqual(respawning); + await remote.dispose(); + await world.dispose(); + }); + it("are held as they are where there's no shared memory", async () => { + vi.stubGlobal("crossOriginIsolated", false); + const world = createWorld({ [workers]: 0, [title]: "Hello" }); + await world.run(spawn, { count: 100, from: 0 }); + await world.run(rename, { suffix: "!" }); + expect(await labels(world)).toEqual(Array.from({ length: 100 }, (_, i) => `${textOf(i)}!`)); + await world.run(edit, { title: "Bye" }); + expect(await readResource(world, title)).toBe("Bye"); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/strings.ts b/packages/ecs/src/implementation/world/strings.ts new file mode 100644 index 000000000..da1242184 --- /dev/null +++ b/packages/ecs/src/implementation/world/strings.ts @@ -0,0 +1,246 @@ +import { coveringBuffer, type Memory } from './memory.js'; + +// Strings in shared memory (design.md, "A string's storage"): a string component's column holds a handle per row, to +// an immutable record in the world's memory, which every thread reads, or 0 for `null`, since offset 0 is the +// directory's header. A record is 8-byte aligned, at byte `handle * 8`: its length in UTF-16 code units, its +// generation, then its code units. Only the world's thread writes records: a write makes a new one and lets the old one +// go, freed once no reader may hold it. The world's thread keeps each value as it is too, which it reads, and keeps +// records only while a joined thread may read them: from the first thread's share until the last is gone. + +/** A thread's records: reading them, and on the world's thread, writing and freeing them. */ +export type StringStore = { + /** Whether this thread writes records: only the world's does. */ + readonly writes: boolean; + /** Whether the world keeps records of its strings, while a joined thread may read them. */ + active: boolean; + /** The text of the record `handle` names, decoded once per record and kept while its generation is the same. */ + readonly read: (handle: number) => string; + /** A new record holding `text`, by its handle. */ + readonly write: (text: string) => number; + /** A new record of the same text, 0 for 0, for a copy a reader on another thread holds past its row's sync points. */ + readonly copy: (handle: number) => number; + /** Lets a record go, 0 for none: it's freed at the end of the second sync point from now, when no reader holds it. */ + readonly retire: (handle: number) => void; + /** At the end of each sync point: frees the records let go before the last one. */ + readonly settle: () => void; +}; + +/** What a cached record's text was decoded from: its handle's generation at the time. */ +type Cached = { generation: number; text: string }; + +/** Code units decoded by one call of `String.fromCharCode`, which takes them as arguments. */ +const decodeChunk = 4096; + +const decode = (units: Uint16Array, start: number, length: number): string => { + if (length <= 8) { + let text = ""; + for (let i = 0; i < length; i++) text += String.fromCharCode(units[start + i]); + return text; + } + let text = ""; + for (let at = 0; at < length; at += decodeChunk) { + const chunk: string = Reflect.apply(String.fromCharCode, null, units.subarray(start + at, start + Math.min(length, at + decodeChunk))); + text += chunk; + } + return text; +}; + +/** A record's bytes for `length` code units: its two words, then its units, rounded up to 8. */ +const recordBytes = (length: number): number => (8 + 2 * length + 7) & ~7; + +/** Records at most this many bytes are carved from slabs, by power-of-two size from 16; larger ones are the arena's blocks. */ +const slabbed = 512; +const slabBytes = 65536; +/** Slab size classes, 16 to 512 bytes. */ +const classes = 6; + +/** A slabbed record's size class: its bytes rounded up to a power of two, from 16. */ +const classOf = (bytes: number): number => Math.max(0, 28 - Math.clz32(bytes - 1)); + +/** The views a store reads through, and the cache of what it decoded, by handle. */ +type Views = { words: Uint32Array; units: Uint16Array; readonly cache: Map }; + +/** Views of the whole buffer, or the same ones while they cover `bytes`; `buffer` gives one covering them. */ +const cover = (views: Views, buffer: (bytes: number) => ArrayBufferLike, bytes: number): void => { + if (views.units.byteLength >= bytes) return; + const latest = buffer(bytes); + views.words = new Uint32Array(latest); + views.units = new Uint16Array(latest); +}; + +const readOf = (views: Views, buffer: (bytes: number) => ArrayBufferLike) => (handle: number): string => { + const word = handle << 1; + cover(views, buffer, handle * 8 + 8); + const { words } = views; + const generation = words[word + 1]; + const cached = views.cache.get(handle); + if (cached !== undefined && cached.generation === generation) return cached.text; + const length = words[word]; + cover(views, buffer, handle * 8 + 8 + 2 * length); + const text = decode(views.units, (handle << 2) + 4, length); + if (cached === undefined) views.cache.set(handle, { generation, text }); + else { + cached.generation = generation; + cached.text = text; + } + return text; +}; + +const unwritten = (): never => { + throw new Error("A string is written on the world's thread only."); +}; + +/** A joined thread's store over `memory`: it reads records, and writes none. */ +export const createStringReader = (memory: WebAssembly.Memory): StringStore => { + const views: Views = { words: new Uint32Array(0), units: new Uint16Array(0), cache: new Map() }; + return { + writes: false, + active: true, + read: readOf(views, bytes => coveringBuffer(memory, bytes)), + write: unwritten, + copy: unwritten, + retire: unwritten, + settle: () => {}, + }; +}; + +/** The world's store over its arena, which only the world's thread writes. */ +export const createStrings = (memory: Memory): StringStore => { + const views: Views = { words: new Uint32Array(0), units: new Uint16Array(0), cache: new Map() }; + // The world's thread grows the memory itself, so its buffer covers every record. + const buffer = (): ArrayBufferLike => memory.memory.buffer; + const read = readOf(views, buffer); + /** By slab size class: freed handles, and the rest of the slab being carved. */ + const free: number[][] = Array.from({ length: classes }, () => []); + const next = new Array(classes).fill(0); + const end = new Array(classes).fill(0); + let generation = 0; + let pending: number[] = []; + let previous: number[] = []; + + const allocate = (bytes: number): number => { + if (bytes > slabbed) return memory.allocate(bytes) >> 3; + const size = classOf(bytes); + const reused = free[size].pop(); + if (reused !== undefined) return reused; + if (next[size] === end[size]) { + const slab = memory.allocate(slabBytes); + next[size] = slab; + end[size] = slab + slabBytes; + } + // Carved by its class's size, so any record of the class fits the slot once it's freed. + const offset = next[size]; + next[size] += 16 << size; + return offset >> 3; + }; + + /** A record of `length` units, its words written and its units left to the caller. */ + const record = (length: number): number => { + const bytes = recordBytes(length); + const handle = allocate(bytes); + cover(views, buffer, handle * 8 + bytes); + generation = (generation + 1) >>> 0; + views.words[handle << 1] = length; + views.words[(handle << 1) + 1] = generation; + return handle; + }; + + const release = (handle: number): void => { + const bytes = recordBytes(views.words[handle << 1]); + if (bytes > slabbed) memory.free(handle << 3, bytes); + else free[classOf(bytes)].push(handle); + }; + + return { + writes: true, + active: false, + read, + write: text => { + const { length } = text; + const handle = record(length); + const { units } = views; + const start = (handle << 2) + 4; + for (let i = 0; i < length; i++) units[start + i] = text.charCodeAt(i); + return handle; + }, + copy: source => { + if (source === 0) return 0; + const length = views.words[source << 1]; + const handle = record(length); + const start = (source << 2) + 4; + views.units.copyWithin((handle << 2) + 4, start, start + length); + return handle; + }, + retire: handle => { + if (handle !== 0) pending.push(handle); + }, + settle: () => { + if (previous.length === 0 && pending.length === 0) return; + for (let k = 0; k < previous.length; k++) release(previous[k]); + previous.length = 0; + const freed = previous; + previous = pending; + pending = freed; + }, + }; +}; + +/** What holds strings: a string's column of handles, its values on the world's thread, and its records. */ +type Column = { readonly views: { readonly u32: Uint32Array }; readonly values: unknown[]; readonly strings: StringStore | null }; + +/** A string column's value at `row`: as the world's thread holds it, or out of its record on another, `null` for 0. */ +export const readString = (column: Column, row: number): unknown => { + const value = column.values[row]; + if (value !== undefined) return value; + const { strings } = column; + if (strings === null) throw new Error("A string column has no records to read."); + const handle = column.views.u32[row]; + return handle === 0 ? null : strings.read(handle); +}; + +/** Replaces a string column's value at `row`, and while records are kept, its record, letting the old one go. */ +export const writeString = (column: Column, row: number, value: unknown): void => { + const { strings } = column; + if (strings === null || !strings.writes) throw new Error("A string is written on the world's thread only."); + column.values[row] = value; + if (!strings.active) return; + const handles = column.views.u32; + const old = handles[row]; + handles[row] = value === null ? 0 : strings.write(typeof value === "string" ? value : String(value)); + strings.retire(old); +}; + +/** Archetypes as records are kept for them: their string storages, each row's value and handle. */ +type Held = { readonly count: number; readonly storages: readonly (Column & { readonly layout: { readonly kind: string } })[] }; + +/** Starts keeping records, once a thread may read them: one for each value the world holds now. */ +export const keepRecords = (strings: StringStore, archetypes: readonly Held[]): void => { + if (strings.active) return; + strings.active = true; + for (const archetype of archetypes) { + for (const storage of archetype.storages) { + if (storage.layout.kind !== "string") continue; + const handles = storage.views.u32; + for (let row = 0; row < archetype.count; row++) { + const value = storage.values[row]; + handles[row] = value === null || value === undefined ? 0 : strings.write(typeof value === "string" ? value : String(value)); + } + } + } +}; + +/** Stops keeping records, once no thread reads them: every one is let go. */ +export const dropRecords = (strings: StringStore, archetypes: readonly Held[]): void => { + if (!strings.active) return; + strings.active = false; + for (const archetype of archetypes) { + for (const storage of archetype.storages) { + if (storage.layout.kind !== "string") continue; + const handles = storage.views.u32; + for (let row = 0; row < archetype.count; row++) { + strings.retire(handles[row]); + handles[row] = 0; + } + } + } +}; diff --git a/packages/ecs/src/implementation/world/structure.ts b/packages/ecs/src/implementation/world/structure.ts new file mode 100644 index 000000000..7c3154a3b --- /dev/null +++ b/packages/ecs/src/implementation/world/structure.ts @@ -0,0 +1,70 @@ +import type { Archetype } from './archetype.js'; +import type { Holders } from './holders.js'; +import { strataOf, type RuleFacts, type Strata } from '../../interface/registry/strata.js'; +import { closedComponents } from './closure.js'; + +/** + * A world's structural derivation: its rules and `within` declarations as strata, the closure each archetype maps to, + * and its references, with the holders of each target. Empty until rules, `within` or references are used, and every + * sync-point step it adds is gated on that. + */ +export type Structure = { + rules: readonly RuleFacts[]; + strata: Strata; + /** By component id: the host of a component declared within one, or -1. */ + readonly hosts: number[]; + /** By host id: the components declared within it. */ + readonly dependents: number[][]; + /** By component id: 0, or the kind of reference it holds (`owner`, then `weak`). */ + readonly references: number[]; + /** Components holding references. */ + readonly referenceIds: number[]; + /** By reference component id: the holders of each target. */ + readonly holders: (Holders | undefined)[]; + /** By archetype id: the archetype id of its closure, or -1 until computed. */ + closures: number[]; + /** By `from + 1` and `to` archetype ids: the derived components an entity gains moving between them. */ + readonly gains: Map; + /** Whether the next sync point re-derives every entity, after rules were registered. */ + rederive: boolean; +}; + +export const owner = 1; +export const weak = 2; + +export const createStructure = (): Structure => ({ + rules: [], strata: [], hosts: [], dependents: [], references: [], referenceIds: [], holders: [], closures: [], gains: new Map(), rederive: false, +}); + +/** Whether the closure can differ from the archetype: some rule or `within` declaration exists. */ +export const derives = (structure: Structure): boolean => structure.strata.length > 0; + +/** Recomputes the strata, throwing for a cycle; on success the cached closures are dropped. */ +export const restratify = (structure: Structure, rules: readonly RuleFacts[], hosts: readonly number[], nameOf: (id: number) => string): void => { + const strata = strataOf(rules, hosts, nameOf); + structure.rules = rules; + structure.strata = strata; + structure.closures = []; + structure.gains.clear(); +}; + +/** The archetype an entity with `archetype`'s components ends up in once derived. Computed once per archetype, at sync points. */ +export const closureOf = (structure: Structure, derived: readonly boolean[], archetype: Archetype, archetypeOf: (components: readonly number[]) => Archetype, archetypes: readonly Archetype[]): Archetype => { + if (archetype.resource || structure.strata.length === 0) return archetype; + const cached = structure.closures[archetype.id] ?? -1; + if (cached >= 0) return archetypes[cached]; + const closed = archetypeOf(closedComponents(archetype.components, derived, structure.hosts, structure.strata)); + structure.closures[archetype.id] = closed.id; + return closed; +}; + +/** The derived components an entity gains moving from `from` (null when spawned) to `to`. */ +export const gainsOf = (structure: Structure, derived: readonly boolean[], from: Archetype | null, to: Archetype): readonly number[] => { + const key = ((from?.id ?? -1) + 1) * 0x200000 + to.id; + let gains = structure.gains.get(key); + if (gains === undefined) { + gains = to.components.filter(id => derived[id] === true && from?.storageById[id] === undefined); + structure.gains.set(key, gains); + } + return gains; +}; diff --git a/packages/ecs/src/implementation/world/synchronize.test.ts b/packages/ecs/src/implementation/world/synchronize.test.ts new file mode 100644 index 000000000..1fea60ddb --- /dev/null +++ b/packages/ecs/src/implementation/world/synchronize.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { createBlock, endBlock, generationOf, indexOf, reserve, reserveFrom } from "./entities.js"; +import { createState } from "./state.js"; +import { synchronize } from "./synchronize.js"; + +/** The indexes on the free list. */ +const freeIndexes = (state: ReturnType): readonly number[] => [...state.entities.free.subarray(0, state.entities.cursor[1])]; + +describe("synchronize", () => { + it("releases a reserved index whose spawn never arrived, so it isn't lost", () => { + const state = createState(); + const orphan = reserve(state.entities); + synchronize(state); + const next = reserve(state.entities); + expect(indexOf(next)).toBe(indexOf(orphan)); + expect(next).not.toBe(orphan); + }); + + it("returns the positions a block left unused to the free list once each, keeping their generations", () => { + const state = createState(); + const block = createBlock(state.entities); + const orphan = reserveFrom(block); + endBlock(block, state.unused); + expect(state.unused).toEqual([1, 8]); + synchronize(state); + const free = freeIndexes(state); + // The orphan was handed out, so the leak scan releases it with a new generation; the rest were never handed out. + expect(free).toHaveLength(8); + expect(new Set(free).size).toBe(8); + expect(free).toContain(indexOf(orphan)); + const next = createBlock(state.entities); + const entities = Array.from({ length: 8 }, () => reserveFrom(next)); + expect(new Set(entities.map(indexOf)).size).toBe(8); + expect(entities.filter(entity => generationOf(entity) !== 0).map(indexOf)).toEqual([indexOf(orphan)]); + }); + + it("maps unused positions past the free list to new indexes", () => { + const state = createState(); + // Two indexes on the free list, then a first block of 8 takes them and 6 new ones. + reserve(state.entities); + reserve(state.entities); + synchronize(state); + expect(freeIndexes(state)).toHaveLength(2); + const block = createBlock(state.entities); + const used = [reserveFrom(block), reserveFrom(block), reserveFrom(block)]; + endBlock(block, state.unused); + synchronize(state); + const free = freeIndexes(state); + expect(free).toHaveLength(8); + expect(new Set(free).size).toBe(8); + for (const entity of used) expect(free).toContain(indexOf(entity)); + expect(Math.max(...free)).toBe(state.entities.length - 1); + }); +}); diff --git a/packages/ecs/src/implementation/world/synchronize.ts b/packages/ecs/src/implementation/world/synchronize.ts new file mode 100644 index 000000000..4fa932a74 --- /dev/null +++ b/packages/ecs/src/implementation/world/synchronize.ts @@ -0,0 +1,762 @@ +import type { Views } from '../../interface/registry/layout.js'; +import { copyValue, ensureCapacity, swapRemove, writeF32, writeF32Lanes, writeF64, writeI32, writeU32, type Archetype, type Storage } from './archetype.js'; +import { chunkShift } from './kernel.js'; +import { addCommand, clearBuffer, despawnCommand, endOf, referenceAt, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; +import { readCell, writeCell } from './cells.js'; +import { commitBatches, commitStaged } from './batches.js'; +import { applyIndexChanges, cascadeReferences, indexChangesOf, type IndexChanges, type Written } from './cascade.js'; +import { refreshDirectories } from './directories.js'; +import { beginStructure, countChanged, endStructure, flushDirectory } from './layout-directory.js'; +import { beginLog, logAppend, logMove, logSet, logSwap, publishLog, startTag, tagOf } from './move-log.js'; +import { record } from './journal.js'; +import { indexOf, kill, locate, materialize, recycle, release, reserved, unreserve, type Reserved } from './entities.js'; +import { activate } from './activate.js'; +import { refreshFinalizerIndexes, runFinalizers, type Thrown } from './finalizers.js'; +import { settleStops } from './stop-lists.js'; +import { closureOf, derives, gainsOf } from './structure.js'; +import type { Plan } from './plan.js'; +import type { MoveLog } from './move-log.js'; +import { archetypeOf, defaultOf, movesOf, withComponent, withoutComponent, type State } from './state.js'; +import { releaseValue } from './transfers.js'; + +/** Buffers queued for the next sync point, in merge order. Commands in guarded buffers can't touch the resource entity. */ +export type Queued = { readonly buffer: CommandBuffer; readonly guarded: boolean }; + +const grownInt32 = (array: Int32Array, length: number): Int32Array => { + const grown = new Int32Array(length); + grown.set(array); + return grown; +}; + +/** Grows the plan to hold `needed` entries. */ +const ensurePlanCapacity = (state: State, needed: number): void => { + const { plan } = state; + if (needed <= plan.entity.length) return; + let length = plan.entity.length * 2; + while (length < needed) length *= 2; + const entity = new Float64Array(length); + entity.set(plan.entity); + plan.entity = entity; + const despawned = new Uint8Array(length); + despawned.set(plan.despawned); + plan.despawned = despawned; + plan.from = grownInt32(plan.from, length); + plan.to = grownInt32(plan.to, length); + plan.row = grownInt32(plan.row, length); + plan.pair = grownInt32(plan.pair, length); + plan.buffer = grownInt32(plan.buffer, length); + plan.start = grownInt32(plan.start, length); + plan.end = grownInt32(plan.end, length); +}; + +const addEntry = (state: State, entity: number, index: number, from: number, to: number): number => { + ensurePlanCapacity(state, state.plan.count + 1); + return addPlanned(state, entity, index, from, to); +}; + +/** Adds an entry to a plan already grown to hold it. */ +const addPlanned = (state: State, entity: number, index: number, from: number, to: number): number => { + const { plan } = state; + const entry = plan.count++; + plan.entity[entry] = entity; + plan.from[entry] = from; + plan.to[entry] = to; + plan.despawned[entry] = 0; + plan.start[entry] = plan.end[entry] = -1; + plan.slots[index] = entry; + return entry; +}; + +const addRange = (state: State, entry: number, buffer: number, start: number, end: number): void => { + const { plan } = state; + if (plan.start[entry] < 0) { + plan.buffer[entry] = buffer; + plan.start[entry] = start; + plan.end[entry] = end; + return; + } + // The next of its commands in the same buffer, as a body's adds to one row are, extends its range. + if (plan.buffer[entry] === buffer && plan.end[entry] === start && !plan.more.has(entry)) { + plan.end[entry] = end; + return; + } + let more = plan.more.get(entry); + if (more === undefined) plan.more.set(entry, more = []); + more.push(buffer, start, end); +}; + +/** The entry of a live entity, added unchanged if it has none; -1 for an entity that isn't alive. */ +const entryOf = (state: State, entity: number): number => { + const index = indexOf(entity); + const existing = state.plan.slots[index]; + if (existing >= 0) return state.plan.entity[existing] === entity ? existing : -1; + if (locate(state.entities, entity) < 0) return -1; + const from = state.entities.archetype[index]; + return addEntry(state, entity, index, from, from); +}; + +/** Removes the components declared within `id` from `to`, transitively, as removing their host does. */ +const withoutDependents = (state: State, to: Archetype, id: number): Archetype => { + let result = to; + for (const dependent of state.structure.dependents[id] ?? []) { + if (result.storageById[dependent] === undefined) continue; + result = withoutDependents(state, withoutComponent(state, result, dependent), dependent); + } + return result; +}; + +/** + * Releases the values a dropped add gives `transfer` components, which no component will hold: here, or on the joined + * thread filling them, which staged them. A drained buffer's values here are tags. + */ +const releaseDropped = (state: State, buffer: CommandBuffer, command: number): void => { + if (buffer.kinds[command] !== addCommand) return; + const end = endOf(buffer, command); + for (let k = buffer.starts[command]; k < end; k++) { + const id = buffer.ids[k]; + if (!state.transfers[id]) continue; + const log = state.remote[id]; + // A joined thread's own are staged there, released once it replays this sync point, which it's woken for. + if (log !== undefined) { + for (const channel of state.channels) if (channel.log === log) channel.dropped = true; + } + else if (buffer.bytes === null && buffer.values[k] !== state.starts[id]) releaseValue(buffer.values[k]); + } +}; + +/** + * Plan: the net change for each entity, in issue order. Commands against dead entities do nothing. Reference values + * written by commands go to `written`, to check once despawns are known. + */ +const planOf = (state: State, queued: readonly Queued[], written: Written): void => { + const { plan, structure } = state; + if (plan.slots.length < state.entities.length) plan.slots = new Int32Array(Math.max(state.entities.length, plan.slots.length * 2)).fill(-1); + const { slots } = plan; + const empty = archetypeOf(state, []).id; + const { archetypes } = state; + const within = derives(structure); + const references = structure.referenceIds.length > 0; + for (let b = 0; b < queued.length; b++) { + const { buffer, guarded } = queued[b]; + const { kinds, entities, starts, ids } = buffer; + // Each command adds at most one entry. + ensurePlanCapacity(state, plan.count + buffer.count); + for (let command = 0; command < buffer.count; command++) { + const kind = kinds[command]; + const entity = entities[command]; + if (guarded && entity === state.resourceEntity) continue; + const index = indexOf(entity); + let entry: number; + // Only the world spawns through a command, its resource entity: systems' and kernels' spawns are staged. + if (kind === spawnCommand) { + entry = addPlanned(state, entity, index, -1, empty); + plan.spawned++; + const start = starts[command]; + const end = endOf(buffer, command); + let to = archetypes[empty]; + for (let k = start; k < end; k++) to = withComponent(state, to, ids[k]); + plan.to[entry] = to.id; + if (end > start) addRange(state, entry, b, start, end); + if (references) for (let k = start; k < end; k++) if (structure.references[ids[k]] !== 0) written.push(entity, ids[k], referenceAt(buffer, k)); + continue; + } + else { + entry = slots[index]; + if (entry >= 0) { + if (plan.entity[entry] !== entity || plan.despawned[entry] === 1) { + plan.dropped++; + if (state.transferring) releaseDropped(state, buffer, command); + continue; + } + } + else { + if (locate(state.entities, entity) < 0) { + plan.dropped++; + if (state.transferring) releaseDropped(state, buffer, command); + continue; + } + const from = state.entities.archetype[index]; + entry = addPlanned(state, entity, index, from, from); + } + } + if (kind === despawnCommand) { + plan.despawned[entry] = 1; + continue; + } + const start = starts[command]; + const end = endOf(buffer, command); + let to = archetypes[plan.to[entry]]; + // A list's command resolves its target from each source archetype once. + const shape = buffer.shapes[command] < 0 ? null : state.shapes[buffer.shapes[command]]; + if (kind === removeCommand) { + const known = shape === null ? undefined : shape.removing[to.id]; + if (known !== undefined) { + plan.to[entry] = known; + continue; + } + const source = to.id; + for (let k = start; k < end; k++) { + to = withoutComponent(state, to, ids[k]); + if (within) to = withoutDependents(state, to, ids[k]); + } + if (shape !== null) shape.removing[source] = to.id; + plan.to[entry] = to.id; + continue; + } + const from = plan.from[entry] < 0 ? null : archetypes[plan.from[entry]]; + const known = shape === null ? undefined : shape.adding[to.id]; + const source = to.id; + for (let k = start; k < end; k++) { + const id = ids[k]; + // Removed earlier in this sync point and added again: a new start, so a new `added` tick. + if (from !== null && from !== to && to.storageById[id] === undefined && from.storageById[id] !== undefined) { + let fresh = plan.fresh.get(entry); + if (fresh === undefined) plan.fresh.set(entry, fresh = []); + fresh.push(id); + } + if (known === undefined) to = withComponent(state, to, id); + if (references && structure.references[id] !== 0) written.push(entity, id, referenceAt(buffer, k)); + } + if (known !== undefined) to = archetypes[known]; + else if (shape !== null) shape.adding[source] = to.id; + plan.to[entry] = to.id; + if (end > start) addRange(state, entry, b, start, end); + } + } +}; + +/** Copies a value's bytes, in its storage's layout, into row `row`: by 32-bit word where its layout has whole words. */ +const copyBytes = (storage: Storage, row: number, bytes: Views, offset: number): void => { + const { words } = storage; + if (words > 0) { + const target = storage.views.u32; + const source = bytes.u32; + const to = row * words; + const from = offset >> 2; + for (let i = 0; i < words; i++) target[to + i] = source[from + i]; + return; + } + const stride = storage.layout.kind === "linear" ? storage.layout.stride : 0; + const target = storage.views.u8; + const source = bytes.u8; + for (let i = 0; i < stride; i++) target[row * stride + i] = source[offset + i]; +}; + +/** A joined thread's object: its tag here, its value given there. */ +const writeRemote = (storage: Storage, log: MoveLog, value: unknown, to: Archetype, toRow: number, from: Archetype | null, fresh: readonly number[] | undefined): void => { + const id = storage.component; + const tag = tagOf(value, log.starts[id]); + storage.tags[toRow] = tag; + logSet(log, to.id, toRow, id, tag, from === null || from.storageById[id] === undefined || (fresh !== undefined && fresh.includes(id))); +}; + +const writeRange = (buffer: CommandBuffer, start: number, end: number, to: Archetype, toRow: number, from: Archetype | null, fresh: readonly number[] | undefined, tick: number): void => { + const { ids, values, bytes, offsets } = buffer; + for (let k = start; k < end; k++) { + const id = ids[k]; + const storage = to.storageById[id]; + if (storage === undefined) continue; + const value = values[k]; + // Inline, not a closure call: common layouts store straight into their view; the rest use the layout's writer. + const { views } = storage; + const index = toRow * storage.unitStride + storage.unitOffset; + // A worker's structs arrive as bytes in their storage's layout. + if (bytes !== null && offsets[k] >= 0) copyBytes(storage, toRow, bytes, offsets[k]); + else switch (storage.write) { + case writeF32: if (typeof value === "number") views.f32[index] = value; else writeCell(storage, toRow, value); break; + case writeU32: if (typeof value === "number") views.u32[index] = value; else writeCell(storage, toRow, value); break; + case writeI32: if (typeof value === "number") views.i32[index] = value; else writeCell(storage, toRow, value); break; + case writeF64: if (typeof value === "number") views.f64[index] = value; else writeCell(storage, toRow, value); break; + case writeF32Lanes: + if (Array.isArray(value)) for (let lane = 0; lane < storage.lanes; lane++) views.f32[index + lane] = Number(value[lane]); + else writeCell(storage, toRow, value); + break; + default: + if (storage.remote === null) writeCell(storage, toRow, value); + else writeRemote(storage, storage.remote, value, to, toRow, from, fresh); + } + if (!storage.ticked) continue; + const starts = from === null || from.storageById[id] === undefined || (fresh !== undefined && fresh.includes(id)); + // A constant's `changed` ticks are its `added` ticks, and its value can't change: only a new start stamps it. + if (storage.changed !== storage.added || starts) { + storage.changed[toRow] = tick; + storage.chunkChanged[toRow >> chunkShift] = tick; + } + if (starts) { + storage.added[toRow] = tick; + storage.chunkAdded[toRow >> chunkShift] = tick; + } + } +}; + +/** Writes a derived component's default into a row it starts in, stamped as added. */ +export const writeDefault = (state: State, storage: Storage, row: number, tick: number): void => { + // A joined thread's object starts there, as the row's move or append says. + if (storage.remote === null) writeCell(storage, row, defaultOf(state.keys[storage.component])); + else storage.tags[row] = startTag; + if (!storage.ticked) return; + storage.added[row] = storage.changed[row] = tick; + storage.chunkAdded[row >> chunkShift] = storage.chunkChanged[row >> chunkShift] = tick; +}; + +/** Writes the values an entry's commands give into its row, in issue order. */ +const writeValues = (state: State, queued: readonly Queued[], entry: number, to: Archetype, toRow: number, from: Archetype | null, tick: number): void => { + const { plan } = state; + if (plan.start[entry] < 0) return; + state.valuesWritten = tick; + const fresh = plan.fresh.size === 0 ? undefined : plan.fresh.get(entry); + writeRange(queued[plan.buffer[entry]].buffer, plan.start[entry], plan.end[entry], to, toRow, from, fresh, tick); + const more = plan.more.size === 0 ? undefined : plan.more.get(entry); + if (more !== undefined) for (let r = 0; r < more.length; r += 3) writeRange(queued[more[r]].buffer, more[r + 1], more[r + 2], to, toRow, from, fresh, tick); +}; + +/** + * The first pass of applying: a despawned entity is released, keeping in `plan.row` the row it leaves, and one staying + * in its archetype gets its values written. Each moving entry, spawns included, joins the pair of archetypes it moves + * between, keeping the row it leaves. + */ +const sort = (state: State, queued: readonly Queued[], tick: number): void => { + const { entities, archetypes, plan } = state; + const { pairs } = plan; + const width = archetypes.length + 1; + let lastKey = -1; + let lastPair = -1; + /** The last entry that joined a pair, to tell whether each pair's entries are consecutive. */ + let lastJoined = -2; + let ranges = true; + let leaving = 0; + /** Indexes a joined thread's finalizers hold, which a despawn kills without releasing; null for none. */ + const holds = state.holds.size > 0 ? state.holds.counts : null; + for (let entry = 0; entry < plan.count; entry++) { + const index = indexOf(plan.entity[entry]); + const fromId = plan.from[entry]; + const fromRow = fromId < 0 ? -1 : entities.row[index]; + plan.pair[entry] = -1; + if (plan.despawned[entry] === 1) { + record(state.journal, index, entities.length); + plan.row[entry] = fromRow; + if (fromRow >= 0) leaving++; + if (fromId >= 0) state.touched?.add(archetypes[fromId]); + if (fromId >= 0 && state.counting) countChanged(state.counted, archetypes[fromId]); + // Held while a joined thread's finalizers haven't acknowledged its stop. + if (holds !== null && index < holds.length && holds[index] > 0) kill(entities, index); + else release(entities, index); + continue; + } + const toId = plan.to[entry]; + if (toId === fromId) { + plan.row[entry] = -1; + const archetype = archetypes[toId]; + writeValues(state, queued, entry, archetype, fromRow, archetype, tick); + state.touched?.add(archetype); + continue; + } + record(state.journal, index, entities.length); + plan.row[entry] = fromRow; + if (fromRow >= 0) leaving++; + const key = (fromId + 1) * width + toId; + // Entries of one pair usually come together, as a system's commands over one archetype do. + let pair = key === lastKey ? lastPair : pairs.byKey.get(key); + if (pair === undefined) { + pair = pairs.from.length; + pairs.byKey.set(key, pair); + pairs.from.push(fromId); + pairs.to.push(toId); + pairs.count.push(0); + pairs.start.push(entry); + } + else if (pair !== lastPair || entry !== lastJoined + 1) ranges = false; + lastKey = key; + lastPair = pair; + lastJoined = entry; + plan.pair[entry] = pair; + pairs.count[pair]++; + } + pairs.leaving = leaving; + if (pairs.order.length < plan.count) pairs.order = new Int32Array(Math.max(plan.count, pairs.order.length * 2)); + const { order } = pairs; + // Each pair's block is its own range of entries. + if (ranges) { + for (let entry = 0; entry < plan.count; entry++) order[entry] = entry; + return; + } + let start = 0; + for (let pair = 0; pair < pairs.count.length; pair++) { + pairs.start[pair] = start; + start += pairs.count[pair]; + pairs.count[pair] = 0; + } + for (let entry = 0; entry < plan.count; entry++) { + const pair = plan.pair[entry]; + if (pair >= 0) order[pairs.start[pair] + pairs.count[pair]++] = entry; + } +}; + +/** Copies one column's cells for a block of moving rows, the row entry `order[first + k]` leaves to `toStart + k`, values and ticks. */ +const copyColumn = (from: Storage, to: Storage, plan: Plan, order: Int32Array, first: number, count: number, toStart: number): void => { + const { words } = from; + if (words > 0) { + const source = from.views.u32; + const target = to.views.u32; + for (let k = 0; k < count; k++) { + const s = plan.row[order[first + k]] * words; + const t = (toStart + k) * words; + for (let i = 0; i < words; i++) target[t + i] = source[s + i]; + } + } + else if (from.layout.kind !== "constant") { + for (let k = 0; k < count; k++) copyValue(from, plan.row[order[first + k]], to, toStart + k); + } + if (!to.ticked) return; + for (let k = 0; k < count; k++) { + const fromRow = plan.row[order[first + k]]; + const toRow = toStart + k; + const added = from.added[fromRow]; + const changed = from.changed[fromRow]; + to.added[toRow] = added; + to.changed[toRow] = changed; + // A chunk's tick is the newest of its rows, so a row moving in can raise it. + const chunk = toRow >> chunkShift; + if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; + if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; + } +}; + +/** + * A pair's rows arriving, once a joined thread may read: the counts that changed, and in an archetype holding joined + * threads' objects, for each thread's log, moves from an archetype holding its objects too, else appends, each of the + * thread's objects the row gains at its start. + */ +const noteArrivals = (state: State, from: Archetype | null, to: Archetype, first: number, count: number, toStart: number): void => { + countChanged(state.counted, to); + if (from !== null) countChanged(state.counted, from); + const { logs } = to; + if (logs === null) return; + const { plan } = state; + const { order } = plan.pairs; + for (const log of logs) { + const moving = from !== null && from.logs !== null && from.logs.includes(log); + for (let k = 0; k < count; k++) { + const entry = order[first + k]; + if (moving) logMove(log, from.id, plan.row[entry], to.id, toStart + k); + else logAppend(log, to.id, toStart + k, plan.entity[entry]); + } + } +}; + +/** + * The second pass: each pair's entries arrive as one block of rows at the end of their target, which presizing made + * room for. The pair resolves once the cells its archetypes share and the derived components the target adds, and + * copies them column by column; then each entry's values are written and its entity placed. + */ +const arrive = (state: State, queued: readonly Queued[], tick: number, deriving: boolean): void => { + const { entities, archetypes, plan } = state; + const { pairs } = plan; + const { order } = pairs; + for (let pair = 0; pair < pairs.from.length; pair++) { + const from = pairs.from[pair] < 0 ? null : archetypes[pairs.from[pair]]; + const to = archetypes[pairs.to[pair]]; + const first = pairs.start[pair]; + const count = pairs.count[pair]; + const toStart = to.count; + for (let k = 0; k < count; k++) to.entities[toStart + k] = plan.entity[order[first + k]]; + to.count += count; + if (from !== null) { + const moves = movesOf(from, to); + for (let m = 0; m < moves.length; m += 2) copyColumn(moves[m], moves[m + 1], plan, order, first, count, toStart); + } + if (state.claimed) noteArrivals(state, from, to, first, count, toStart); + // Rows are reused after swap-removes, so a derived component starting here gets its default written. + if (deriving) { + for (const id of gainsOf(state.structure, state.derived, from, to)) { + const storage = to.storageById[id]; + if (storage !== undefined) for (let k = 0; k < count; k++) writeDefault(state, storage, toStart + k, tick); + } + } + for (let k = 0; k < count; k++) { + const entry = order[first + k]; + const index = indexOf(plan.entity[entry]); + writeValues(state, queued, entry, to, toStart + k, from, tick); + entities.archetype[index] = to.id; + entities.row[index] = toStart + k; + } + if (state.touched !== null) { + state.touched.add(to); + if (from !== null) state.touched.add(from); + } + } +}; + +/** + * Removes the row an entry left, by moving its archetype's last row into it. Entries are removed last first, so + * rows they left at the end of an archetype, as a whole archetype moving out leaves them, are dropped without a copy. + * A row moved into the hole that another entry still leaves takes that entry's place; any other's entity is updated. + */ +const leave = (state: State, entry: number): void => { + const { plan } = state; + const row = plan.row[entry]; + if (row < 0) return; + const from = state.archetypes[plan.from[entry]]; + if (from.strings.length !== 0) releaseStrings(state, from, row, plan.despawned[entry] === 1 ? null : state.archetypes[plan.to[entry]]); + const moved = swapRemove(from, row); + if (moved === -1) return; + const index = indexOf(moved); + const other = plan.slots[index]; + if (other >= 0 && plan.entity[other] === moved && plan.row[other] >= 0 && plan.from[other] === from.id) plan.row[other] = row; + else { + state.entities.row[index] = row; + // Its row changed, so what `up` keeps of it does. + record(state.journal, index, state.entities.length); + } +}; + +/** Lets go of the strings' records a row leaving `from` holds that its entity doesn't take to `to`, null for a despawn. */ +const releaseStrings = (state: State, from: Archetype, row: number, to: Archetype | null): void => { + const { strings } = state; + if (strings === null || !strings.active) return; + const held = from.strings; + for (let s = 0; s < held.length; s++) { + const storage = held[s]; + if (to === null || to.storageById[storage.component] === undefined) strings.retire(storage.views.u32[row]); + } +}; + +/** `leave`, once a joined thread may read: a row removed from an archetype holding its objects goes in its log first. */ +const leaveLogged = (state: State, entry: number): void => { + const { plan } = state; + const row = plan.row[entry]; + if (row >= 0) { + const from = state.archetypes[plan.from[entry]]; + if (from.logs !== null) for (const log of from.logs) logSwap(log, from.id, from.count - 1, row); + } + leave(state, entry); +}; + +/** Grows each archetype once to fit the entities moving or spawning into it. */ +const presize = (state: State): void => { + const { plan } = state; + const incoming = new Int32Array(state.archetypes.length); + for (let entry = 0; entry < plan.count; entry++) if (plan.despawned[entry] === 0 && plan.from[entry] !== plan.to[entry]) incoming[plan.to[entry]]++; + for (let id = 0; id < incoming.length; id++) { + const archetype = state.archetypes[id]; + if (incoming[id] > 0) ensureCapacity(state.memory, archetype, archetype.count + incoming[id]); + } +}; + +const merged = (carried: Reserved | null, reservations: Reserved): Reserved => { + if (carried === null) return reservations; + const reused = new Int32Array(carried.reused.length + reservations.reused.length); + reused.set(carried.reused); + reused.set(reservations.reused, carried.reused.length); + return { reused, firstNew: Math.min(carried.firstNew, reservations.firstNew) }; +}; + +/** Every live entity whose archetype's closure differs gets an entry, after rules were registered. */ +const rederive = (state: State): void => { + const { structure, archetypes } = state; + structure.rederive = false; + for (const archetype of [...archetypes]) { + if (archetype.resource || archetype.count === 0) continue; + const closed = closureOf(structure, state.derived, archetype, components => archetypeOf(state, components), archetypes); + if (closed === archetype) continue; + for (let row = 0; row < archetype.count; row++) { + const entity = archetype.entities[row]; + if (state.plan.slots[indexOf(entity)] < 0) addEntry(state, entity, indexOf(entity), archetype.id, archetype.id); + } + } +}; + +/** Undoes a planned sync point's shared state, after it applied or failed. */ +const cleanUp = (state: State, queued: readonly Queued[]): void => { + const { plan } = state; + // The plan's arrays are reused by the next sync point, so they're always left clean, even after a throw. + for (let entry = 0; entry < plan.count; entry++) plan.slots[indexOf(plan.entity[entry])] = -1; + plan.count = 0; + plan.spawned = 0; + plan.dropped = 0; + plan.more.clear(); + plan.fresh.clear(); + const { pairs } = plan; + pairs.byKey.clear(); + pairs.from.length = pairs.to.length = pairs.count.length = pairs.start.length = 0; + // Systems reuse their buffers next run. + for (const { buffer } of queued) clearBuffer(buffer); + refreshDirectories(state); + flushDirectory(state.directory, state.bumped, state.archetypes, state.entities, state.resourceEntity, state.counted); + // Published once the records name what its entries do, and before the epoch turns even. + for (let k = 0; k < state.logs.length; k++) publishLog(state.logs[k]); + endStructure(state.directory); + state.syncing = null; + // Threads whose finalizers have new stops run them now, whether or not a task or observer post would replay them, + // as do threads with staged values to release. + for (let k = 0; k < state.channels.length; k++) { + const channel = state.channels[k]; + if (!channel.stops.unwoken && !channel.dropped) continue; + channel.stops.unwoken = false; + channel.dropped = false; + channel.wake(); + } +}; + +/** Whether a sync point would apply anything: commands queued, entities reserved or carried, rules to derive again, or failures to report. */ +export const syncPending = (state: State, queued: readonly Queued[] = state.queued): boolean => + !queued.every(({ buffer }) => buffer.count === 0) || reserved(state.entities) || state.carried !== null || state.structure.rederive + // Failures for its report, as a gone thread's stops it never finalized are. + || state.unplaced.length > 0; + +/** + * The first half of a sync point: commits batch spawns, plans every command, cascades references and closes the plan + * under rules. Nothing that exists moves yet, so the GPU can read back what the plan disturbs before `applySync`. + * Returns false when there's nothing to apply. An `isolated` one applies only the queued buffers, without batches, + * re-deriving or finalizers, for registration's own changes between runs. + */ +export const prepareSync = (state: State, isolated = false): boolean => { + if (!isolated) activate(state); + // Taken first, so a sync point that throws can't replay its commands into the next one. + const queued = state.queued.splice(0); + const { structure, plan } = state; + // Finalizers' spawns are reserved during the last sync point, so their batches are pending without a reservation. + if (!syncPending(state, queued)) return false; + // Finalizers see indexes as of before the sync point applies, with nothing it spawns or moves. + if (!isolated) refreshFinalizerIndexes(state); + plan.count = 0; + plan.spawned = 0; + plan.dropped = 0; + let reservations: Reserved | null = null; + beginStructure(state.directory); + try { + // Indexes joined threads finalized are released, reusable after this sync point. + if (state.claimed) for (let k = 0; k < state.channels.length; k++) settleStops(state.channels[k].stops, state.holds, state.entities.released); + const made = materialize(state.memory, state.entities); + // Positions blocks left unused name reservations of this sync point's own. + const { unused } = state; + let unreserved = 0; + for (let k = 0; k < unused.length; k += 2) unreserved += unreserve(state.entities, made, unused[k], unused[k + 1]); + unused.length = 0; + reservations = merged(state.carried, made); + state.carried = null; + const tick = ++state.tick; + for (let k = 0; k < state.logs.length; k++) beginLog(state.logs[k], tick); + const deriving = derives(structure); + const close = (archetype: Archetype): Archetype => closureOf(structure, state.derived, archetype, components => archetypeOf(state, components), state.archetypes); + const born = new Map(); + // Before the plan, so commands against staged entities find them, and before any growth, which copies `count` + // rows. + if (!isolated) plan.spawned += commitBatches(state, tick, born, deriving ? close : null); + plan.spawned += commitStaged(state, queued, tick, born, deriving ? close : null); + const written: Written = []; + planOf(state, queued, written); + if (structure.rederive && !isolated) rederive(state); + if (deriving) for (let entry = 0; entry < plan.count; entry++) if (plan.despawned[entry] === 0) plan.to[entry] = close(state.archetypes[plan.to[entry]]).id; + let index: IndexChanges | null = null; + if (structure.referenceIds.length > 0) { + for (const [id, first] of born) { + const archetype = state.archetypes[id]; + for (const component of structure.referenceIds) { + const storage = archetype.storageById[component]; + if (storage === undefined) continue; + for (let row = first; row < archetype.count; row++) written.push(archetype.entities[row], component, Number(readCell(storage, row))); + } + } + cascadeReferences(state, queued, written, entity => entryOf(state, entity)); + const fromOf = (entry: number): Archetype | null => { + const from = plan.from[entry]; + if (from < 0) return null; + const first = born.get(from); + return first !== undefined && state.entities.row[indexOf(plan.entity[entry])] >= first ? null : state.archetypes[from]; + }; + index = indexChangesOf(state, queued, fromOf, [...born].map(([id, first]) => [state.archetypes[id], first] as const)); + } + // References change only here: each component written to is stamped, and each holder journaled, as are spawns. + for (let k = 1; k < written.length; k += 3) state.referencesWritten[written[k]] = tick; + if (state.journal.readers > 0) { + for (let k = 0; k < written.length; k += 3) record(state.journal, indexOf(written[k]), state.entities.length); + for (const [id, first] of born) { + const archetype = state.archetypes[id]; + for (let row = first; row < archetype.count; row++) record(state.journal, indexOf(archetype.entities[row]), state.entities.length); + } + } + presize(state); + state.syncing = { queued, reservations, reserved: state.entities.length, tick, born, index, deriving, isolated, unreserved }; + return true; + } + catch (error) { + // The next sync point's leak scan frees reservations whose spawns this one dropped. + if (reservations !== null) state.carried = reservations; + cleanUp(state, queued); + throw error; + } +}; + +/** What a sync point whose finalizers threw nothing returns: read-only, so one is shared. */ +const nothingThrown: readonly Thrown[] = []; + +/** + * The second half: runs finalizers against the plan, then applies it. A finalizer that throws doesn't stop the sync + * point; what each threw is returned once it has applied. + */ +export const applySync = (state: State): readonly Thrown[] => { + const syncing = state.syncing; + if (syncing === null) return nothingThrown; + const { queued, reservations, tick, born, index, deriving, isolated, unreserved } = syncing; + const tableLength = syncing.reserved; + const { plan } = state; + let errors = nothingThrown; + try { + if (state.unplaced.length > 0 && !isolated) errors = state.unplaced.splice(0); + if (state.finalizers.length > 0 && !isolated) { + const thrown = runFinalizers(state, queued, born, tick); + errors = errors.length === 0 ? thrown : [...errors, ...thrown]; + // Finalizers reserve entities for their spawns; the next sync point accounts for them. + if (reserved(state.entities)) state.carried = materialize(state.memory, state.entities); + } + sort(state, queued, tick); + arrive(state, queued, tick, deriving); + if (plan.pairs.leaving > 0) { + if (state.claimed) for (let entry = plan.count - 1; entry >= 0; entry--) leaveLogged(state, entry); + else for (let entry = plan.count - 1; entry >= 0; entry--) leave(state, entry); + } + if (index !== null) applyIndexChanges(state, index); + // A reservation whose spawn never arrived, from a chunk that failed after reserving, would otherwise leak its + // index. Every spawn consumes one reservation and the rest were unreserved, so only a shortfall needs the scan, + // which skips what's released already. Finalizers' reservations came after, so they're left for the next sync + // point. + if (plan.spawned + unreserved < reservations.reused.length + tableLength - reservations.firstNew) { + const released = new Set(state.entities.released); + const leaked = (index: number): boolean => (index >= plan.slots.length || plan.slots[index] < 0) && state.entities.archetype[index] < 0 && !released.has(index); + for (const index of reservations.reused) if (leaked(index)) release(state.entities, index); + for (let index = reservations.firstNew; index < tableLength; index++) if (leaked(index)) release(state.entities, index); + } + recycle(state.memory, state.entities); + } + finally { + cleanUp(state, queued); + } + // Only after a sync point that applied, so a failing one keeps its own error. + for (const after of state.afterSync) after.deref()?.(); + return errors; +}; + +/** Applies every queued command as one sync point with a new tick, then empties the queue. */ +export const synchronize = (state: State): void => { + if (prepareSync(state)) applySync(state); +}; + +/** + * Applies `buffer` alone as a sync point, leaving what's pending for the next run's: registration's changes between + * runs, which no finalizer sees. + */ +export const synchronizeAlone = (state: State, buffer: CommandBuffer): void => { + const pending = state.queued.splice(0); + const carried = state.carried; + state.carried = null; + state.queued.push({ buffer, guarded: false }); + try { + if (prepareSync(state, true)) applySync(state); + } + finally { + state.queued.push(...pending); + // An isolated sync point runs no finalizers, so it reserves nothing for the next one. + state.carried = carried; + } +}; diff --git a/packages/ecs/src/implementation/world/tail-blocks.ts b/packages/ecs/src/implementation/world/tail-blocks.ts new file mode 100644 index 000000000..a3565734e --- /dev/null +++ b/packages/ecs/src/implementation/world/tail-blocks.ts @@ -0,0 +1,74 @@ +import { lineWords } from './protocol.js'; + +// What the world's thread and a joined thread share to run a tail (threads.md, "Tails"): a block per tail system, of +// three lines of words, and the snapshot the world copies its reads into when a writer is ready before it finished. +// +// A tail's work is numbered: position 0 is its head, which checks its conditions and resolves its resources, and +// positions from 1 are the rows of its matched archetypes in order. The tail claims positions in chunks by adding to +// its progress word; the world exchanges that word for `snapshotted` once it copied the passing rows from the position +// it read, so a claim that returns `snapshotted` or more reads the rest from the copy. + +/** The positions the tail claimed, which it adds to and the world exchanges; and the end of its work, 1 plus its rows. */ +export const progressWord = 0; +export const endWord = 1; +/** The position the tail finished reading through, which it stores after each chunk; whether the world waits for it. */ +export const doneWord = lineWords; +export const waitingWord = lineWords + 1; +/** The snapshot: its offset, rows, the position it starts from, and its head's flags (`ranFlag`, `executedFlag`). */ +export const snapshotOffsetWord = 2 * lineWords; +export const snapshotRowsWord = 2 * lineWords + 1; +export const snapshotStartWord = 2 * lineWords + 2; +export const snapshotFlagsWord = 2 * lineWords + 3; +/** Where the resource entity was as of the tail's sync, for its objects there: its archetype and row. */ +export const snapshotResourceArchetypeWord = 2 * lineWords + 4; +export const snapshotResourceRowWord = 2 * lineWords + 5; +/** The rows the snapshot is laid out for, at least its rows. */ +export const snapshotCapacityWord = 2 * lineWords + 6; +export const tailBlockWords = 3 * lineWords; +export const tailBlockBytes = tailBlockWords * 4; + +/** What the progress word holds once the world copied the rest: claims add to it and stay past it. */ +export const snapshotted = 0x40000000; +/** Positions a claim takes: the most the world waits for once a writer is ready. */ +export const tailChunk = 64; + +/** The snapshot's head flags: its conditions held, and it ran its rows or its body. */ +export const headExecuted = 1; +export const headRan = 2; + +const align = (bytes: number): number => (bytes + 7) & ~7; + +/** + * Where a snapshot of `rows` rows keeps each column, past its offset, each on 8 bytes: every row's position, + * archetype and row (u32), entity (f64), then each linear component read of `strides` bytes, then each linear resource + * read's value of `resourceStrides` bytes; and its bytes in all. + */ +export type SnapshotLayout = { + readonly positions: number; + readonly archetypes: number; + readonly rows: number; + readonly entities: number; + readonly columns: readonly number[]; + readonly resources: readonly number[]; + readonly bytes: number; +}; + +export const snapshotLayout = (rows: number, strides: readonly number[], resourceStrides: readonly number[]): SnapshotLayout => { + const words = align(4 * rows); + const positions = 0; + const archetypes = words; + const rowsAt = 2 * words; + const entities = 3 * words; + let at = entities + 8 * rows; + const columns: number[] = []; + for (const stride of strides) { + columns.push(at); + at += align(stride * rows); + } + const resources: number[] = []; + for (const stride of resourceStrides) { + resources.push(at); + at += align(stride); + } + return { positions, archetypes, rows: rowsAt, entities, columns, resources, bytes: Math.max(at, 8) }; +}; diff --git a/packages/ecs/src/implementation/world/tails-worker.fixture.ts b/packages/ecs/src/implementation/world/tails-worker.fixture.ts new file mode 100644 index 000000000..7d9c5f178 --- /dev/null +++ b/packages/ecs/src/implementation/world/tails-worker.fixture.ts @@ -0,0 +1,66 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { requireDefinition } from "../../interface/registry/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld, join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { changed, gate, seed, seen, tailSystems, ticked, type Seen } from "./tails.test-helper.js"; +import { tailSnapshots } from "./thread-systems.js"; +import { reportOf, runReported } from "./reports.test-helper.js"; + +/** + * A thread joining a world through the port it's sent, claiming "tailsTest", whose systems it imports: it tells what + * its tails saw, and how often they read from a snapshot, when asked. Or one holding both a world and the thread + * joining it, which leaves while two runs of its tails are queued: it tells what each reported, once both settled and + * the world placed the tails here again. + */ + +type Port = MessagePort | NodeMessagePort; +export type ToTailsWorker = { readonly kind: "join"; readonly port: Port } | { readonly kind: "seen" } | { readonly kind: "leave queued" } | { readonly kind: "gate"; readonly words: Int32Array }; +export type FromTailsWorker = + | { readonly kind: "joined" } + | { readonly kind: "gated" } + | { readonly kind: "left"; readonly skipped: readonly (readonly string[])[]; readonly after: number } + | { readonly kind: "seen"; readonly seen: readonly Seen[]; readonly changed: readonly (readonly [number, number])[]; readonly late: number; readonly switched: number }; + +const channel = scopeChannel(); +/** + * The world and the joined thread on this one: the thread leaves while two runs are queued, so the second posts its + * tails to a thread already gone. Then a run waits for every tail in flight, as the world placing them here again does. + */ +const leaveQueued = async (): Promise => { + const world = createWorld({ [workers]: 0 }); + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const remote = join(port2, { threads: ["tailsTest"] }); + await new Promise(resolve => { + const stop = world.threads(threads => { + if (threads[0]?.status !== "joined") return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + await world.run(seed, { count: 10 }); + await world.run(ticked); + const leaving = remote.dispose(); + const runs = [world.run(ticked), world.run(ticked)]; + const reports = await Promise.all((await Promise.all(runs)).map(run => reportOf(world, run))); + await leaving; + const after = await runReported(world, ticked); + await world.dispose(); + return { kind: "left", skipped: reports.map(report => report.skipped), after: after.failures.length }; +}; + +channel.onMessage(async message => { + if ("protocol" in message) return; + if (message.kind === "leave queued") channel.post(await leaveQueued()); + else if (message.kind === "join") { + for (const system of tailSystems) requireDefinition(system); + join(message.port, { threads: ["tailsTest"] }); + channel.post({ kind: "joined" }); + } + else if (message.kind === "gate") { + gate.words = message.words; + channel.post({ kind: "gated" }); + } + else channel.post({ kind: "seen", seen, changed: [...changed], late: tailSnapshots.late, switched: tailSnapshots.switched }); +}); diff --git a/packages/ecs/src/implementation/world/tails.cases.ts b/packages/ecs/src/implementation/world/tails.cases.ts new file mode 100644 index 000000000..56641dc1d --- /dev/null +++ b/packages/ecs/src/implementation/world/tails.cases.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./tails.fixture.js"; + +/** Tails on a joined worker: the cases, over the fixture built for node or from source. */ +export const tailsCases = (load: () => Promise) => describe("tails on a joined worker", () => { + it("see one tick's values in every run while the world ticks on: in place, from a snapshot when late, and both across a switch at a chunk", async () => { + const fixture = await load(); + const seen = await fixture.continuous(); + expect({ wrong: seen.wrong, labels: seen.labels, fewest: seen.fewest, most: seen.most }).toEqual({ wrong: 0, labels: 0, fewest: 1000, most: 1000 }); + expect(seen.late).toBeGreaterThan(0); + expect(seen.switched).toBeGreaterThan(0); + }, 30000); + it("see one tick's values in every run, never from a snapshot, when writers wait for them instead", async () => { + const fixture = await load(); + const seen = await fixture.waiting(); + expect({ wrong: seen.wrong, labels: seen.labels, fewest: seen.fewest, most: seen.most, late: seen.late, switched: seen.switched }).toEqual({ wrong: 0, labels: 0, fewest: 1000, most: 1000, late: 0, switched: 0 }); + }, 30000); + it("never snapshot when prompt", async () => { + const fixture = await load(); + const seen = await fixture.prompt(); + expect({ runs: seen.runs, wrong: seen.wrong, labels: seen.labels, late: seen.late, switched: seen.switched }).toEqual({ runs: 30, wrong: 0, labels: 0, late: 0, switched: 0 }); + }, 30000); + it("read a snapshot taken before a sync point grows what they read, what they write from it stamped as changed", async () => { + const fixture = await load(); + const seen = await fixture.growing(); + expect({ wrong: seen.wrong, labels: seen.labels, fewest: seen.fewest, most: seen.most, uncounted: seen.uncounted }).toEqual({ wrong: 0, labels: 0, fewest: 1000, most: 21000, uncounted: 0 }); + expect(seen.late).toBeGreaterThan(0); + expect(seen.counted).toBeGreaterThan(10); + }, 30000); + it("read the strings of their tick when runs after it would free and reuse their records, since those runs wait for them", async () => { + const fixture = await load(); + const seen = await fixture.spanning(); + expect({ runs: seen.runs, wrong: seen.wrong, fewest: seen.fewest, most: seen.most }).toEqual({ runs: 1, wrong: 0, fewest: 1000, most: 1000 }); + }, 30000); + it("aren't waited for by a sync point when they read only their thread's objects", async () => { + const fixture = await load(); + expect(await fixture.heldBody()).toBe(true); + }, 30000); + it("are skipped in every run queued when their thread left, and nothing waits for a tail on a thread that's gone", async () => { + const fixture = await load(); + const skipped = ["tailsTest/lead", "tailsTest/watch"]; + expect(await fixture.leaveQueued()).toEqual({ skipped: [skipped, skipped], after: 0 }); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/tails.fixture.ts b/packages/ecs/src/implementation/world/tails.fixture.ts new file mode 100644 index 000000000..cd6d8bfac --- /dev/null +++ b/packages/ecs/src/implementation/world/tails.fixture.ts @@ -0,0 +1,254 @@ +/// +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import { backToBack } from "./loop.js"; +import { tailWriters } from "./tails.js"; +import type { FromTailsWorker, ToTailsWorker } from "./tails-worker.fixture.js"; +import { counter, heldTick, noStall, reword, seed, setSpawning, setStall, stallAtChunk, stallBefore, tallied, ticked } from "./tails.test-helper.js"; + +/** + * Scenarios of tails on a joined worker, run built in node and from source in chromium: a terminal system there over + * every row while the world ticks on, reading them where they are, from a snapshot when it's late, or both across a + * switch at a chunk; a prompt one that never needs a snapshot; and a sync point growing what a late one reads. + */ + +type Worker = { readonly ask: (message: ToTailsWorker, transfer?: readonly unknown[]) => Promise; readonly terminate: () => Promise }; + +const workerFile = "./tails-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./tails-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer = []) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => { if (!("protocol" in event.data)) resolve(event.data); }; + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer = []) => new Promise(resolve => { + const reply = (data: FromTailsWorker): void => { + if ("protocol" in data) return; + worker.off("message", reply); + resolve(data); + }; + worker.on("message", reply); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + }; +}; + +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +const joined = async (world: CreatedWorld): Promise<{ readonly worker: Worker }> => { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + await worker.ask({ kind: "join", port: port2 }, [port2]); + await until(world.threads, threads => threads.at(-1)?.status === "joined"); + return { worker }; +}; + +/** Ticks on until the counter passed `ticks` more. */ +const ticks = async (world: World, more: number): Promise => { + const now = await until(Observe.resource(world, counter), () => true); + await until(Observe.resource(world, counter), value => value >= now + more); +}; + +/** What the worker's tails saw: their runs, those that saw another tick's structs or labels, the fewest rows, and snapshots. */ +const summary = async (worker: Worker) => { + const reply = await worker.ask({ kind: "seen" }); + if (reply.kind !== "seen") throw new Error("tails: the worker didn't say what it saw."); + const { seen } = reply; + return { + runs: seen.length, + wrong: seen.reduce((sum, run) => sum + run.wrong, 0), + labels: seen.reduce((sum, run) => sum + run.labels, 0), + fewest: Math.min(...seen.map(run => run.rows)), + most: Math.max(...seen.map(run => run.rows)), + late: reply.late, + switched: reply.switched, + // Each run of `tally` after the first counts every row the watcher wrote the tick before. + uncounted: reply.changed.filter(([value, rows]) => value > 2 && rows !== seen.find(run => run.counter === value - 1)?.rows).length, + counted: reply.changed.length, + }; +}; + +const within = (promise: Promise, milliseconds: number, otherwise: T): Promise => + Promise.race([promise, new Promise(resolve => setTimeout(() => resolve(otherwise), milliseconds))]); + +/** + * A world and its joined thread on the worker, the thread leaving with two runs of its tails queued: what they + * reported, or null when the worker never answered, as when its world waits forever for a gone thread's tail. + */ +export const leaveQueued = async () => { + const worker = start(); + const reply = await within(worker.ask({ kind: "leave queued" }), 20000, null); + await worker.terminate(); + return reply !== null && reply.kind === "left" ? { skipped: reply.skipped, after: reply.after } : null; +}; + +/** 1,000 rows while the world ticks back to back: prompt, then stalling at the end of a chunk, then late. */ +export const continuous = async () => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const { worker } = await joined(world); + await world.run(seed, { count: 1000 }); + world.add(ticked); + await ticks(world, 30); + await world.run(setStall, { mode: stallAtChunk, pause: 3 }); + await ticks(world, 30); + await world.run(setStall, { mode: stallBefore, pause: 3 }); + await ticks(world, 30); + await world.run(setStall, { mode: noStall, pause: 0 }); + await ticks(world, 10); + await world.dispose(); + const seen = await summary(worker); + await worker.terminate(); + driver.close(); + return seen; +}; + +/** As `continuous`, with writers waiting until each tail is done rather than copying what it hasn't read. */ +export const waiting = async () => { + tailWriters.wait = true; + try { + return await continuous(); + } + finally { + tailWriters.wait = false; + } +}; + +/** + * Frames the caller gives one at a time: `next` resolves once the loop asked for its next beat and runs it, so a tick + * starts only when the caller says, however slowly the machine runs. + */ +const gatedFrames = () => { + let pending: ((time: number) => void) | null = null; + let requested: (() => void) | null = null; + return { + frame: (callback: (time: number) => void): (() => void) => { + pending = callback; + requested?.(); + requested = null; + return () => { + if (pending === callback) pending = null; + }; + }, + next: async (): Promise => { + if (pending === null) await new Promise(resolve => { requested = resolve; }); + const callback = pending; + pending = null; + callback?.(performance.now()); + }, + }; +}; + +/** Asks what the worker's tails saw until the watcher's run for `value` read every one of `rows` rows. */ +const watched = async (worker: Worker, value: number, rows: number): Promise => { + for (;;) { + const reply = await worker.ask({ kind: "seen" }); + if (reply.kind === "seen" && reply.seen.some(run => run.counter === value && run.rows === rows)) return; + } +}; + +/** + * 1,000 rows, each tick starting only once the last one's tail on the worker read them all: the tail finished before + * the next tick writes, however slowly either thread runs. + */ +export const prompt = async () => { + const frames = gatedFrames(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: frames }); + const { worker } = await joined(world); + await world.run(seed, { count: 1000 }); + world.add(ticked); + for (let tick = 1; tick <= 30; tick++) { + await frames.next(); + await until(Observe.resource(world, counter), value => value >= tick); + await watched(worker, tick, 1000); + } + await world.dispose(); + const seen = await summary(worker); + await worker.terminate(); + return seen; +}; + +/** A late tail while a sync point spawns 20,000 rows into the archetype it reads, growing it and the move log. */ +export const growing = async () => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const { worker } = await joined(world); + await world.run(seed, { count: 1000 }); + await world.run(setStall, { mode: stallBefore, pause: 3 }); + world.add(tallied); + await ticks(world, 5); + await world.run(setSpawning, { count: 20000 }); + await ticks(world, 10); + await world.run(setStall, { mode: noStall, pause: 0 }); + await ticks(world, 5); + await world.dispose(); + const seen = await summary(worker); + await worker.terminate(); + driver.close(); + return seen; +}; + +/** + * A tail stalled before it reads the words of its tick while the world's runs rewrite every word six times, which + * would free and reuse the records it reads were the runs not to wait for it. + */ +export const spanning = async () => { + const frames = gatedFrames(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: frames }); + const { worker } = await joined(world); + await world.run(seed, { count: 1000 }); + await world.run(setStall, { mode: stallBefore, pause: 200 }); + world.add(ticked); + await frames.next(); + await until(Observe.resource(world, counter), value => value >= 1); + for (let round = 0; round < 6; round++) await world.run(reword, { round }); + await watched(worker, 1, 1000); + await world.dispose(); + const seen = await summary(worker); + await worker.terminate(); + return seen; +}; + +/** + * A tail reading only its thread's objects, held in its body, while a run spawns rows: whether the run finished before + * the tail was let go, as its sync point writes nothing the tail reads. + */ +export const heldBody = async (): Promise => { + const frames = gatedFrames(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: frames }); + const { worker } = await joined(world); + const words = new Int32Array(new SharedArrayBuffer(8)); + await worker.ask({ kind: "gate", words }); + await world.run(seed, { count: 10 }); + world.add(heldTick); + await frames.next(); + while (Atomics.load(words, 1) === 0) await new Promise(resolve => setTimeout(resolve, 1)); + const ran = await within(world.run(seed, { count: 10 }).then(() => true), 5000, false); + Atomics.store(words, 0, 1); + Atomics.notify(words, 0); + await world.dispose(); + await worker.terminate(); + return ran; +}; diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts new file mode 100644 index 000000000..9f6daad12 --- /dev/null +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -0,0 +1,166 @@ +import { Component } from "../../interface/component/component.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; + +// Keys and systems both threads of the tails fixtures import: a world system writing every row's stamp each tick, and +// terminal systems on thread "tailsTest", which a joined thread fills, running there as tails. + +export const stamp = Symbol("tailsTest/stamp"); +export const spot = Symbol("tailsTest/spot"); +export const label = Symbol("tailsTest/label"); +export const counter = Symbol("tailsTest/counter"); +export const stall = Symbol("tailsTest/stall"); +export const spawning = Symbol("tailsTest/spawning"); +export const failAt = Symbol("tailsTest/failAt"); +export const canvas = Symbol("tailsTest/canvas"); +export const word = Symbol("tailsTest/word"); +declare module "@adobe/ecs" { + export interface Threads { + tailsTest: true; + } + export interface Components { + [stamp]: { type: number }; + [spot]: { type: readonly [number, number, number] }; + [label]: { type: { readonly text: string } | null; thread: "tailsTest" }; + [word]: { type: string }; + } + export interface Resources { + [counter]: { type: number }; + [stall]: { type: { readonly mode: number; readonly pause: number } }; + [spawning]: { type: number }; + [failAt]: { type: number }; + [canvas]: { type: { readonly text: string }; thread: "tailsTest" }; + } +} +Component.register(stamp, { type: { type: "integer" } }); +Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); +/** An object of the tail's thread, which the tail writes. */ +const text = { type: "object", properties: { text: { type: "string" } } } as const; +Component.register(label, { type: { anyOf: [text, { type: "null" }], default: null }, thread: "tailsTest" }); +/** A string the world writes each tick, which lives in shared memory where the tail reads it. */ +Component.register(word, { type: { type: "string" } }); +Resource.register(counter, { type: { type: "integer", default: 0 } }); +/** + * How the tails stall, for milliseconds `pause`: `"none"`, `"chunk"` in the watcher at the end of its first chunk, so + * a writer is ready mid-way, or `"before"` in a tail ahead of it, so the watcher is late. + */ +Resource.register(stall, { + type: { type: "object", properties: { mode: { type: "integer" }, pause: { type: "number" } }, required: ["mode", "pause"], default: { mode: 0, pause: 0 } }, +}); +Resource.register(spawning, { type: { type: "integer", default: 0 } }); +Resource.register(failAt, { type: { type: "integer", default: -1 } }); +/** An object resource of the tail's thread, which places the systems reading it there. */ +Resource.register(canvas, { type: { ...text, default: { text: "canvas" } }, thread: "tailsTest" }); + +export const noStall = 0; +export const stallAtChunk = 1; +export const stallBefore = 2; + +const spin = (milliseconds: number): void => { + const until = performance.now() + milliseconds; + while (performance.now() < until); +}; + +/** On the world's thread: the counter moves on once a tick. */ +export const count = System.create({ name: "tailsTest/count", resources: { write: [counter] } }, ({ resources }) => { + resources[counter] += 1; +}); +/** A tick's word, of a length that varies with it, so records of every size are freed and reused. */ +export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; + +/** On the world's thread: every row's stamp, spot and word take the counter. */ +export const advance = System.create({ name: "tailsTest/advance", components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { + const value = resources[counter]; + row[stamp] = value; + row[spot] = [value, -value, value]; + row[word] = wordOf(value); +}); +/** On the world's thread: every row's word as another, of the size of the first ticks', so their records are reused. */ +export const reword = System.create({ name: "tailsTest/reword", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [word] } }, (row, { input }) => { + row[word] = `r${input.round}`; +}); +/** On the world's thread: spawns rows stamped with this tick's counter, `spawning` of them, then no more. */ +export const grow = System.create({ name: "tailsTest/grow", resources: { read: [counter], write: [spawning] }, after: [count] }, ({ resources, commands }) => { + const value = resources[counter]; + for (let k = 0; k < resources[spawning]; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value) }); + resources[spawning] = 0; +}); +/** Spawns `count` rows. */ +export const seed = System.create({ name: "tailsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); +}); +export const setStall = System.create({ name: "tailsTest/setStall", input: { type: "object", properties: { mode: { type: "integer" }, pause: { type: "number" } } }, resources: { write: [stall] } }, ({ input, resources }) => { + resources[stall] = { mode: input.mode, pause: input.pause }; +}); +export const setSpawning = System.create({ name: "tailsTest/setSpawning", input: { type: "object", properties: { count: { type: "integer" } } }, resources: { write: [spawning] } }, ({ input, resources }) => { + resources[spawning] = input.count; +}); +export const setFailAt = System.create({ name: "tailsTest/setFailAt", input: { type: "object", properties: { at: { type: "integer" } } }, resources: { write: [failAt] } }, ({ input, resources }) => { + resources[failAt] = input.at; +}); + +/** What each run of the watcher saw, by the counter it read: rows, and those whose structs, word or label weren't that tick's. */ +export type Seen = { counter: number; rows: number; wrong: number; labels: number }; +export const seen: Seen[] = []; + +/** On the tail's thread, ahead of the watcher: stalls it while `stall` says so, so it's late. */ +export const lead = System.create({ name: "tailsTest/lead", resources: { read: [stall, canvas] } }, ({ resources }) => { + if (resources[stall].mode === stallBefore && resources[canvas].text === "canvas") spin(resources[stall].pause); +}); + +/** + * A tail on thread "tailsTest": reads every row's structs, which must all be of one tick, and writes its label, which + * must be the last tick's where it wrote one. + */ +export const watch = System.create({ + name: "tailsTest/watch", components: { read: [stamp, spot, word], write: [label] }, resources: { read: [counter, stall, failAt] }, after: [advance, grow, lead], +}, (row, { resources }) => { + const value = resources[counter]; + let last = seen.at(-1); + if (last === undefined || last.counter !== value) seen.push(last = { counter: value, rows: 0, wrong: 0, labels: 0 }); + if (resources[failAt] === value) throw new Error(`tailsTest failure at ${value}`); + const [x, y, z] = row[spot]; + if (row[stamp] !== value || x !== value || y !== -value || z !== value || row[word] !== wordOf(value)) last.wrong++; + const previous = row[label]?.text ?? null; + if (previous !== null && previous !== String(value - 1) && previous !== String(value)) last.labels++; + row[label] = { text: String(value) }; + last.rows++; + if (last.rows === 63 && resources[stall].mode === stallAtChunk) spin(resources[stall].pause); +}); + +/** Not terminal: it reaches its commands, so its tier blocks. */ +export const blocking = System.create({ name: "tailsTest/blocking", components: { read: [label] } }, (row, { commands }) => { + if (row[label]?.text === "never") commands.despawn(row.entity); +}); + +/** By counter, rows whose label changed since `tally` last ran. */ +export const changed = new Map(); +/** + * On the tail's thread, ahead of the watcher, reaching its commands, so its tier blocks: counts the labels the watcher + * wrote since it last ran, which it sees once the world stamped them, from the watcher's snapshot too. + */ +export const tally = System.create({ name: "tailsTest/tally", components: { read: [label], changed: [label] }, resources: { read: [counter] }, after: [count], before: [watch] }, (row, { resources, commands }) => { + if (row[label]?.text === "never") commands.despawn(row.entity); + changed.set(resources[counter], (changed.get(resources[counter]) ?? 0) + 1); +}); + +/** The words a held tail and the world share, once the world gives them: the gate opens at [0], the tail is held at [1]. */ +export const gate: { words: Int32Array | null } = { words: null }; + +/** On the tail's thread, terminal, reading only that thread's objects: held in its body until the world opens the gate. */ +export const held = System.create({ name: "tailsTest/held", resources: { read: [canvas] } }, ({ resources }) => { + const { words } = gate; + if (words === null || resources[canvas].text !== "canvas") return; + Atomics.store(words, 1, 1); + Atomics.notify(words, 1); + Atomics.wait(words, 0, 0); +}); +export const heldTick = System.create({ name: "tailsTest/heldTick", systems: [held] }); + +/** Every tick: the world's systems, then the tails. */ +export const ticked = System.create({ name: "tailsTest/ticked", systems: [count, advance, grow, lead, watch] }); +/** The same, with `tally` counting what the watcher wrote. */ +export const tallied = System.create({ name: "tailsTest/tallied", systems: [count, advance, grow, lead, tally, watch] }); + +/** Every system the joined thread runs, which it imports so the world can place them there. */ +export const tailSystems: readonly System[] = [ticked, tallied, blocking, heldTick]; diff --git a/packages/ecs/src/implementation/world/tails.test.ts b/packages/ecs/src/implementation/world/tails.test.ts new file mode 100644 index 000000000..cca1e8ca8 --- /dev/null +++ b/packages/ecs/src/implementation/world/tails.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { Report } from "../../interface/report/report.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { backToBack } from "./loop.js"; +import { blocking, counter, seed, seen, setFailAt, ticked } from "./tails.test-helper.js"; +import { System } from "../../interface/system/system.js"; +import { followReports, runReported } from "./reports.test-helper.js"; + +// Tails, with the world and the thread filling "tailsTest" on this one, joined through a MessageChannel. + +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +/** Once the thread shared last has status `status`. */ +const statusOf = (world: CreatedWorld, status: "joined" | "gone") => + until(world.threads, threads => threads.at(-1)?.status === status); + +const joined = async (options: Parameters[1] = {}, resources: Parameters[0] = {}): Promise<{ readonly world: CreatedWorld; readonly remote: World }> => { + const world = createWorld({ [workers]: 0, ...resources }, options); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["tailsTest"] }); + await statusOf(world, "joined"); + return { world, remote }; +}; + +describe("tails", () => { + it("run terminal systems on a joined thread after the sync point, unwarned, each seeing one tick's values; a run waits for them", async () => { + seen.length = 0; + const { world, remote } = await joined(); + await world.run(seed, { count: 300 }); + for (let k = 0; k < 5; k++) expect(await runReported(world, ticked)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + expect(seen.map(({ counter, rows, wrong, labels }) => [counter, rows, wrong, labels])).toEqual([1, 2, 3, 4, 5].map(value => [value, 300, 0, 0])); + await remote.dispose(); + await world.dispose(); + }); + it("block in their tier where a body reaches its commands, which isn't terminal", async () => { + const { world, remote } = await joined(); + expect((await runReported(world, blocking)).warnings.map(warning => warning.name)).toEqual(["tailsTest/blocking"]); + await remote.dispose(); + await world.dispose(); + }); + it("report what they throw in their run's report, and in their tick's, written after them", async () => { + seen.length = 0; + const driver = backToBack(); + const { world, remote } = await joined({ loop: driver }, { [always]: true }); + await world.run(seed, { count: 10 }); + await world.run(setFailAt, { at: 1 }); + const ran = await runReported(world, ticked); + expect(ran.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "tailsTest/watch", "tailsTest failure at 1"]]); + const failing = await world.run(setFailAt, { at: 6 }); + const reports = followReports(world); + world.add(ticked); + const failed = await reports.until(value => value.run > failing && value.failures.length > 0); + expect(failed.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "tailsTest/watch", "tailsTest failure at 6"]]); + await remote.dispose(); + await world.dispose(); + driver.close(); + }); + it("leave observer systems each tick's values at once, and one reading reports its tick's, its tails' failures in it", async () => { + const driver = backToBack(); + const { world, remote } = await joined({ loop: driver }, { [always]: true }); + await world.run(seed, { count: 10 }); + const counters: number[] = []; + const values = System.create({ observer: true, resources: { read: [counter] } }, ({ resources }) => { counters.push(resources[counter]); }); + world.add(ticked); + world.add(values); + await until(Observe.resource(world, counter), value => value >= 5); + world.remove(values); + // Every tick's counter once, as its sync point left it, though its tails ran on while the next tick went on. + const first = counters.findIndex(value => value > 0); + expect(counters.slice(first, first + 4)).toEqual([0, 1, 2, 3].map(k => counters[first] + k)); + const reports: (readonly [number, readonly string[]])[] = []; + const reading = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] }, resources: { read: [counter] } }, (row, { resources }) => { + reports.push([resources[counter], row[Report.errors].failures.map(failure => failure.message)]); + }); + world.add(reading); + const followed = followReports(world); + const at = (await Observe.toPromise(Observe.resource(world, counter))) + 4; + await world.run(setFailAt, { at }); + await followed.until(value => value.failures.length > 0); + expect(reports.filter(([value]) => value === at).map(([, messages]) => messages)).toEqual([[`tailsTest failure at ${at}`]]); + await remote.dispose(); + await world.dispose(); + driver.close(); + }); + it("are skipped with a \"thread\" failure once their thread is gone, then run on the world's thread", async () => { + seen.length = 0; + const { world, remote } = await joined(); + await world.run(seed, { count: 10 }); + await world.run(ticked); + const leaving = remote.dispose(); + const report = await runReported(world, ticked); + await leaving; + expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["thread", "tailsTest/lead"], ["thread", "tailsTest/watch"]]); + expect(report.skipped).toEqual(["tailsTest/lead", "tailsTest/watch"]); + expect(await runReported(world, ticked)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + expect(seen.map(({ counter, rows, wrong }) => [counter, rows, wrong])).toEqual([[1, 10, 0], [3, 10, 0]]); + // A registration waits for tails in flight, of which a thread that's gone has none. + expect((await runReported(world, blocking)).failures).toEqual([]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts new file mode 100644 index 000000000..80b36fcc7 --- /dev/null +++ b/packages/ecs/src/implementation/world/tails.ts @@ -0,0 +1,477 @@ +import type { Leaf } from '../../interface/system/leaf.js'; +import type { Failure } from '../../interface/report/failure.js'; +import { declarationOf, firstKeyOf, labelOfItem } from '../../interface/registry/index.js'; +import { layoutOf, type Layout } from '../../interface/registry/layout.js'; +import { accessOf, type Schedule } from '../../interface/registry/schedule.js'; +import { stampChanged, type Archetype } from './archetype.js'; +import { readCell } from './cells.js'; +import { indexOf, locate } from './entities.js'; +import { failureOf } from './failures.js'; +import { ownTag } from './move-log.js'; +import { forEachPassingRange, type Filter } from './ranges.js'; +import { findResource } from './resources.js'; +import { liftColumnStamps, type RemoteSystem, type TaskChannel } from './remote-systems.js'; +import type { Runner } from './runner.js'; +import { idOf, type Key, type State } from './state.js'; +import { provenTerminal } from './terminal.js'; +import { + doneWord, endWord, headExecuted, headRan, progressWord, snapshotFlagsWord, snapshotLayout, snapshotOffsetWord, snapshotResourceArchetypeWord, + snapshotResourceRowWord, snapshotRowsWord, snapshotStartWord, snapshotCapacityWord, snapshotted, tailBlockBytes, tailBlockWords, waitingWord, +} from './tail-blocks.js'; + +// Tails on the world's thread (threads.md, "Tails"): terminal systems placed on joined threads run after their tick's +// sync point, without the world waiting for them. A later writer of what a tail still reads first copies the rest of +// its reads into a snapshot, and waits for at most the chunk the tail claimed. + +const terminalLeaves = new WeakMap(); + +const objectsOnly = (keys: readonly Key[] | undefined): boolean => + (keys ?? []).every(key => layoutOf(declarationOf(key)?.type).kind === "object"); + +/** + * Whether a leaf is terminal: the plugin proved its body never reaches `commands`, and it writes only objects, which + * live on its own thread, reduces into nothing, and looks nothing up, since a lookup reads the entity table the next + * sync point rewrites. Neither a kernel nor `up`. + */ +export const isTerminal = (leaf: Leaf): boolean => { + const known = terminalLeaves.get(leaf); + if (known !== undefined) return known; + const { declaration } = leaf; + const terminal = leaf.kind === "system" && declaration.kernel === undefined && declaration.up === undefined && provenTerminal(leaf.body) + && Object.keys(declaration.lookup ?? {}).length === 0 + && (declaration.reduce === undefined || Object.getOwnPropertySymbols(declaration.reduce).length === 0) + && objectsOnly(declaration.components?.write) && objectsOnly(declaration.resources?.write); + terminalLeaves.set(leaf, terminal); + return terminal; +}; + +const conflicts = (a: Leaf, b: Leaf): boolean => { + const first = accessOf(a); + const second = accessOf(b); + for (const key of first.writes) if (second.writes.has(key) || second.reads.has(key) || second.reduces.has(key)) return true; + for (const key of second.writes) if (first.reads.has(key) || first.reduces.has(key)) return true; + return false; +}; + +/** + * By tier, whether each leaf runs as a tail where it's placed now: a terminal system on a joined thread that no leaf + * of a later tier conflicts with, so running it after the sync point changes nothing another system reads. + */ +export const tailsOf = (schedule: Schedule, runnerOf: (leaf: Leaf) => Runner): readonly (readonly boolean[])[] => + schedule.tiers.map((tier, t) => tier.map(leaf => { + if (runnerOf(leaf).remote === null || !isTerminal(leaf)) return false; + for (let later = t + 1; later < schedule.tiers.length; later++) for (const next of schedule.tiers[later]) if (conflicts(leaf, next)) return false; + return true; + })); + +/** + * What a writer copies of a reader that runs after a sync point, as a tail or an observer system on a joined thread + * does: the archetypes it matches, its reads and filters, its block and its snapshot, and how to tell its thread is + * gone or idle. + */ +export type Copied = { + readonly matched: () => readonly Archetype[]; + /** Struct components it reads or filters on, and struct resources it reads, conditions on or filters on. */ + readonly reads: readonly number[]; + readonly resourceReads: readonly number[]; + /** + * Its linear component reads, in `read` order, and linear resource reads, in its context's order, with strides: a + * string's are its handles, whose records outlive any snapshot's reader, which is done before the second sync point + * after the one it reads. + */ + readonly linear: readonly number[]; + readonly strides: readonly number[]; + readonly resources: readonly number[]; + readonly resourceStrides: readonly number[]; + /** The objects it writes, components and resources, whose tags turn its own once it reads from a snapshot. */ + readonly writes: readonly number[]; + readonly resourceObjectWrites: readonly number[]; + readonly added: readonly number[]; + readonly changed: readonly number[]; + readonly resourceChanged: readonly number[]; + readonly conditionIs: readonly number[]; + readonly conditionNot: readonly number[]; + readonly query: boolean; + readonly block: number; + readonly control: Int32Array; + snapshot: { offset: number; bytes: number } | null; + /** As posted: its work's end, 1 plus its rows, and its `lastRun`. */ + end: number; + lastRun: number; + /** Whether its thread is gone, and what resolves once its thread has no task in flight or is gone. */ + readonly gone: () => boolean; + readonly idle: () => Promise; +}; + +/** A system's run as a tail: what it reads and writes, its block, its snapshot, and its task in flight. */ +export type Tail = Copied & { + readonly remote: RemoteSystem; + readonly runner: Runner; + /** Whether a writer must still copy what it reads. */ + unresolved: boolean; +}; + +/** The reads of `ids` in linear memory, a string's handles included, with their strides. */ +export const linearOf = (state: State, ids: readonly number[]): { readonly ids: readonly number[]; readonly strides: readonly number[] } => { + const linear: number[] = []; + const strides: number[] = []; + for (const id of ids) { + const layout: Layout = state.layouts[id]; + if (layout.kind !== "linear" && layout.kind !== "string") continue; + linear.push(id); + strides.push(layout.stride); + } + return { ids: linear, strides }; +}; + +const isStruct = (state: State, id: number): boolean => state.layouts[id].kind !== "object"; + +/** A runner's tail, made the first time it runs as one; its block is the channel's, freed with it. */ +export const tailOf = (state: State, runner: Runner, leaf: Leaf): Tail => { + const remote = runner.remote; + if (remote === null) throw new Error(`System "${labelOfItem(leaf)}" runs as a tail only on a joined thread.`); + if (remote.tail !== null) return remote.tail; + const { components, resources } = leaf.declaration; + const ids = (keys: readonly Key[] | undefined): number[] => [...new Set((keys ?? []).map(key => idOf(state, key)))]; + const reads = ids([...components?.read ?? [], ...components?.added ?? [], ...components?.changed ?? []]).filter(id => isStruct(state, id)); + const writes = new Set(resources?.write); + const contextKeys = [...new Set([...resources?.read ?? [], ...resources?.is ?? [], ...writes].map(firstKeyOf))]; + const resourceReads = ids([...resources?.read ?? [], ...resources?.is ?? [], ...resources?.not ?? [], ...resources?.changed ?? []]).filter(id => isStruct(state, id)); + const linear = linearOf(state, ids(components?.read)); + const linearResources = linearOf(state, contextKeys.filter(key => !writes.has(key)).map(key => idOf(state, key))); + const block = state.memory.allocate(tailBlockBytes); + const control = new Int32Array(state.memory.memory.buffer, block, tailBlockWords).fill(0); + const { channel } = remote; + const tail: Tail = { + remote, runner, reads, resourceReads, matched: runner.matched, gone: channel.gone, idle: () => channel.idle(), + linear: linear.ids, strides: linear.strides, resources: linearResources.ids, resourceStrides: linearResources.strides, + writes: ids(components?.write), resourceObjectWrites: ids(resources?.write), + added: ids(components?.added), changed: ids(components?.changed), resourceChanged: ids(resources?.changed), + conditionIs: ids(resources?.is), conditionNot: ids(resources?.not), + query: components !== undefined, block, control, snapshot: null, end: 0, lastRun: 0, unresolved: false, + }; + remote.tail = tail; + remote.channel.owned.push(tail); + return tail; +}; + +/** Frees a channel's tails' blocks and snapshots, once its thread left. */ +export const freeTail = (state: State, tail: Tail): void => { + state.memory.free(tail.block, tailBlockBytes); + if (tail.snapshot !== null) state.memory.free(tail.snapshot.offset, tail.snapshot.bytes); + tail.snapshot = null; +}; + +/** What a schedule's tails gave back once their tasks settled: failures and skipped systems, null for none. */ +export type TailOutcome = { readonly failures: Failure[] | null; readonly skipped: string[] | null }; + +const settledTail = (state: State, tail: Tail): void => { + tail.unresolved = false; + const at = state.tails.indexOf(tail); + if (at >= 0) state.tails.splice(at, 1); +}; + +/** Readies a tail's block for its task: its end, from the rows its query matches now, and its `lastRun`. */ +const prepare = (state: State, tail: Tail): void => { + const { control, runner } = tail; + let rows = 0; + if (tail.query) for (const archetype of runner.matched()) rows += archetype.count; + tail.end = 1 + rows; + tail.lastRun = tail.remote.lastRun; + for (const word of [progressWord, doneWord, waitingWord, snapshotOffsetWord, snapshotRowsWord, snapshotStartWord, snapshotFlagsWord]) Atomics.store(control, word, 0); + Atomics.store(control, endWord, tail.end); + liftColumnStamps(runner); + tail.unresolved = true; + state.tails.push(tail); +}; + +/** + * After a sync point: posts the tails of `runners`, those of each channel as one task in schedule order. Each channel + * must have no task in flight. Resolves with their outcome once every task settled. A tail removed since `runners` were + * read, by a body that ran while the caller waited for its channel, is in no task: its thread drops it once the tasks + * posted before its removal are done. + */ +export const postTails = (state: State, runners: readonly Runner[], leaves: readonly Leaf[], tiers: readonly number[], args: unknown): Promise => { + const channels: TaskChannel[] = []; + for (const runner of runners) if (runner.remote !== null && !runner.holdings.removed && !channels.includes(runner.remote.channel)) channels.push(runner.remote.channel); + const tasks = channels.map(channel => { + const systems: RemoteSystem[] = []; + const order: number[] = []; + const tails: Tail[] = []; + for (let k = 0; k < runners.length; k++) { + const remote = runners[k].remote; + if (remote === null || remote.channel !== channel || runners[k].holdings.removed) continue; + const tail = tailOf(state, runners[k], leaves[k]); + prepare(state, tail); + systems.push(remote); + order.push(tiers[k]); + tails.push(tail); + } + return channel.run(state, systems, order, args, true).then(done => { + for (const tail of tails) settledTail(state, tail); + return { done, systems }; + }); + }); + return outcomeOf(tasks); +}; + +const outcomeOf = async (tasks: readonly Promise<{ readonly done: boolean; readonly systems: readonly RemoteSystem[] }>[]): Promise => { + let failures: Failure[] | null = null; + let skipped: string[] | null = null; + for (const { done, systems } of await Promise.all(tasks)) { + for (const system of systems) { + if (!done) { + (failures ??= []).push(failureOf("thread", system.label, system.channel.goneError())); + (skipped ??= []).push(system.label); + } + else if (system.result.error !== undefined) (failures ??= []).push(failureOf("system", system.label, system.result.error)); + } + } + return { failures, skipped }; +}; + +/** + * For benchmarks and tests: `wait`, a writer of what a tail still reads waits until the tail is done instead of copying + * the rest, the alternative `tail overlap waiting` measures snapshots against; and `copies`, the copies writers took, + * which tests wait for to order a thread's reads after one. + */ +export const tailWriters = { wait: false, copies: 0 }; + +/** How long the world spins for a tail's chunk before it waits: about one wake-up's latency. */ +const spinMilliseconds = 0.02; + +/** Waits until the tail read through `target`, or its thread is gone. */ +export const waitDone = async (tail: Pick, target: number): Promise => { + const { control } = tail; + const deadline = performance.now() + spinMilliseconds; + while (Atomics.load(control, doneWord) < target && performance.now() < deadline); + if (Atomics.load(control, doneWord) >= target) return; + Atomics.store(control, waitingWord, 1); + for (let current = Atomics.load(control, doneWord); current < target && !tail.gone(); current = Atomics.load(control, doneWord)) { + if (typeof Atomics.waitAsync !== "function") { + await Promise.race([new Promise(resolve => setTimeout(resolve, 0)), tail.idle()]); + continue; + } + const waited = Atomics.waitAsync(control, doneWord, current); + if (waited.async) await Promise.race([waited.value, tail.idle()]); + } +}; + +/** Runs of consecutive passing rows a snapshot copies, reused: archetype id, first row, end, and position of the first. */ +const runs: number[] = []; + +const holding = (state: State, id: number): boolean => { + const cell = findResource(state, state.keys[id]); + if (cell === undefined) return false; + const held = readCell(cell.storage, cell.row); + return held !== null && held !== false; +}; + +/** Its head as the tail evaluates it: whether its conditions hold, and from which tick rows pass, -1 for every row. */ +const headOf = (state: State, tail: Copied): { readonly executed: boolean; readonly ran: boolean; readonly since: number } => { + if (!tail.conditionIs.every(id => holding(state, id)) || tail.conditionNot.some(id => holding(state, id))) return { executed: false, ran: false, since: 0 }; + let resourcesChanged = false; + for (const id of tail.resourceChanged) { + const cell = findResource(state, state.keys[id]); + if (cell !== undefined && cell.storage.changed[cell.row] > tail.lastRun) resourcesChanged = true; + } + const filtered = tail.added.length + tail.changed.length + tail.resourceChanged.length > 0; + const since = !filtered || resourcesChanged ? -1 : tail.lastRun; + if (!tail.query) return { executed: true, ran: since < 0, since }; + if (since >= 0 && tail.added.length + tail.changed.length === 0) return { executed: true, ran: false, since }; + return { executed: true, ran: true, since }; +}; + +const filtersOf = (tail: Copied, archetype: Archetype): Filter[] => { + const filters: Filter[] = []; + for (const id of tail.added) { + const storage = archetype.storageById[id]; + if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded }); + } + for (const id of tail.changed) { + const storage = archetype.storageById[id]; + if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged }); + } + return filters; +}; + +/** + * Copies the tail's reads from position `from` on: its head's flags and resources, and each passing row's position, + * archetype, row and entity with its linear reads, a run of rows at a time, into a snapshot laid out for as many rows + * as it could hold. + */ +const writeSnapshot = (state: State, tail: Copied, from: number): void => { + const { control } = tail; + const head = headOf(state, tail); + runs.length = 0; + let rows = 0; + if (tail.query && head.ran) { + const matched = tail.matched(); + let base = 1; + for (let m = 0; m < matched.length; m++) { + const archetype = matched[m]; + const { count } = archetype; + const first = Math.max(0, from - base); + if (first < count) { + const position = base; + forEachPassingRange(filtersOf(tail, archetype), head.since, first, count, (begin, end) => { + runs.push(archetype.id, begin, end, position + begin); + rows += end - begin; + }); + } + base += count; + } + } + const capacity = Math.max(rows, 1); + const layout = snapshotLayout(capacity, tail.strides, tail.resourceStrides); + if (tail.snapshot === null || tail.snapshot.bytes < layout.bytes) { + if (tail.snapshot !== null) state.memory.free(tail.snapshot.offset, tail.snapshot.bytes); + const bytes = Math.max(layout.bytes, 2 * (tail.snapshot?.bytes ?? 0), 256); + tail.snapshot = { offset: state.memory.allocate(bytes), bytes }; + } + const { offset } = tail.snapshot; + const buffer = state.memory.memory.buffer; + const u8 = new Uint8Array(buffer); + const u32 = new Uint32Array(buffer); + const f64 = new Float64Array(buffer, 0, buffer.byteLength >> 3); + const positions = (offset + layout.positions) >> 2; + const archetypes = (offset + layout.archetypes) >> 2; + const rowWords = (offset + layout.rows) >> 2; + const entities = (offset + layout.entities) >> 3; + let at = 0; + for (let r = 0; r < runs.length; r += 4) { + const archetype = state.archetypes[runs[r]]; + const begin = runs[r + 1]; + const end = runs[r + 2]; + const position = runs[r + 3]; + for (let row = begin, i = at; row < end; row++, i++) { + u32[positions + i] = position + row - begin; + u32[archetypes + i] = archetype.id; + u32[rowWords + i] = row; + } + f64.set(archetype.entities.subarray(begin, end), entities + at); + for (let k = 0; k < tail.linear.length; k++) { + const views = archetype.storageById[tail.linear[k]]?.views; + const stride = tail.strides[k]; + if (views !== undefined) u8.set(views.u8.subarray(begin * stride, end * stride), offset + layout.columns[k] + at * stride); + } + at += end - begin; + } + for (let k = 0; k < tail.resources.length; k++) { + const cell = findResource(state, state.keys[tail.resources[k]]); + const stride = tail.resourceStrides[k]; + if (cell !== undefined) u8.set(cell.storage.views.u8.subarray(cell.row * stride, (cell.row + 1) * stride), offset + layout.resources[k]); + } + const resourceIndex = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + Atomics.store(control, snapshotResourceArchetypeWord, resourceIndex < 0 ? -1 : state.entities.archetype[resourceIndex]); + Atomics.store(control, snapshotResourceRowWord, resourceIndex < 0 ? 0 : state.entities.row[resourceIndex]); + Atomics.store(control, snapshotOffsetWord, offset); + Atomics.store(control, snapshotCapacityWord, capacity); + Atomics.store(control, snapshotRowsWord, rows); + Atomics.store(control, snapshotStartWord, from); + Atomics.store(control, snapshotFlagsWord, (head.executed ? headExecuted : 0) | (head.ran ? headRan : 0)); +}; + +/** + * The objects a tail writes in rows it reads from the snapshot, from position `claimed` on, become its own value in + * their tags, which the thread holds, so the sync point moves and finalizes them as such. + */ +const markOwn = (state: State, tail: Copied, claimed: number): void => { + if (tail.writes.length > 0) for (let r = 0; r < runs.length; r += 4) { + const archetype = state.archetypes[runs[r]]; + const begin = Math.max(runs[r + 1], runs[r + 1] + claimed - runs[r + 3]); + const end = runs[r + 2]; + for (const id of tail.writes) { + const storage = archetype.storageById[id]; + if (storage === undefined || storage.tags.length === 0) continue; + for (let row = begin; row < end; row++) storage.tags[row] = ownTag; + } + } + if (tail.resourceObjectWrites.length === 0 || state.resourceEntity < 0) return; + const index = indexOf(state.resourceEntity); + const archetype = state.archetypes[state.entities.archetype[index]]; + for (const id of tail.resourceObjectWrites) { + const storage = archetype?.storageById[id]; + if (storage !== undefined && storage.tags.length > 0) storage.tags[state.entities.row[index]] = ownTag; + } +}; + +/** + * Before a writer of what a tail reads: unless it read everything, copies the rest from the position it reached, + * exchanges its progress for `snapshotted`, and waits for the chunk it claimed. Null when nothing is left to wait for. + */ +const resolve = (state: State, tail: Tail): Promise | null => { + settledTail(state, tail); + return copyRest(state, tail); +}; + +/** + * Unless the reader read everything, copies the rest of what it reads from the position it reached, exchanges its + * progress for `snapshotted`, and waits for the chunk it claimed. Null when nothing is left to wait for. + */ +export const copyRest = (state: State, tail: Copied): Promise | null => { + const { control, end } = tail; + if (Atomics.load(control, doneWord) >= end) return null; + if (tailWriters.wait) return waitDone(tail, end); + const from = Atomics.load(control, progressWord); + runs.length = 0; + if (from < end) writeSnapshot(state, tail, from); + const claimed = Atomics.exchange(control, progressWord, snapshotted); + tailWriters.copies++; + // Rows the tail claimed while this copied it reads where they are. + if (claimed < end) markOwn(state, tail, claimed); + const target = Math.min(claimed, end); + if (target === 0 || Atomics.load(control, doneWord) >= target) return null; + return waitDone(tail, target); +}; + +const any = (ids: readonly number[], among: readonly number[]): boolean => { + for (let i = 0; i < ids.length; i++) if (among.includes(ids[i])) return true; + return false; +}; + +const settleEach = (state: State, conflicting: (tail: Tail) => boolean): Promise | null => { + let waits: Promise[] | null = null; + for (const tail of [...state.tails]) { + if (!tail.unresolved || !conflicting(tail)) continue; + const waiting = resolve(state, tail); + if (waiting !== null) (waits ??= []).push(waiting); + } + return waits === null ? null : waits.length === 1 ? waits[0] : Promise.all(waits).then(() => undefined); +}; + +/** Before runners that write components or resources: resolves each tail reading one. Null when none waits. */ +export const settleRunners = (state: State, runners: readonly Runner[]): Promise | null => settleEach(state, tail => { + for (const runner of runners) { + if (runner.remote !== null && runner.remote.tail === tail) continue; + if (any(runner.writes, tail.reads) || any(runner.resourceWrites, tail.resourceReads)) return true; + } + return false; +}); + +/** Before the world writes these components or resources itself, as GPU readbacks and the clock do. */ +export const settleKeys = (state: State, components: readonly number[], resources: readonly number[]): Promise | null => + settleEach(state, tail => any(components, tail.reads) || any(resources, tail.resourceReads)); + +/** + * Whether a tail reads the world's memory: rows, or struct resources. One that doesn't, reading only its own thread's + * objects, has nothing a sync point writes, so its sync points don't wait for its body. + */ +const readsMemory = (tail: Tail): boolean => tail.query || tail.resourceReads.length > 0; + +/** Before a sync point applies: it counts as a write to everything a tail reads, since growth frees blocks it may reuse. */ +export const settleAll = (state: State): Promise | null => settleEach(state, readsMemory); + +/** + * Stamps the objects a tail wrote from its snapshot, as the thread names them by entity and component, changed at its + * tick where the entity is now; one despawned since has nothing to stamp. + */ +export const stampWritten = (state: State, stamps: readonly number[], position: number, tick: number): void => { + for (let k = 0; k < stamps.length; k += 3) { + if (stamps[k] !== position) continue; + const index = locate(state.entities, stamps[k + 1]); + if (index < 0) continue; + const storage = state.archetypes[state.entities.archetype[index]]?.storageById[stamps[k + 2]]; + if (storage !== undefined) stampChanged(storage, state.entities.row[index], tick); + } +}; diff --git a/packages/ecs/src/implementation/world/target.test.ts b/packages/ecs/src/implementation/world/target.test.ts new file mode 100644 index 000000000..708db7b94 --- /dev/null +++ b/packages/ecs/src/implementation/world/target.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { Component, Entity, System, workers } from "../../index.js"; +import { createWorld } from "./create.js"; + +const amount = Symbol("targetTest/amount"); +const tagged = Symbol("targetTest/tagged"); +declare module "@adobe/ecs" { + interface Components { + [amount]: { type: number }; + [tagged]: { type: true }; + } +} +Component.register(amount, { type: { type: "number" } }); +Component.register(tagged, { type: { const: true } }); + +const one = { type: "object", properties: { entity: Entity.schema } } as const; + +let spawned: Entity[] = []; +const spawn = System.create({ name: "targetTest/spawn" }, ({ commands }) => { + spawned = [commands.spawn([amount, tagged])({ [amount]: 1 }), commands.spawn([amount, tagged])({ [amount]: 2 }), commands.spawn([amount])({ [amount]: 3 })]; +}); +const bump = System.create({ name: "targetTest/bump", input: one, target: "entity", components: { write: [amount], has: [tagged] } }, row => { + row[amount] += 10; +}); +const remove = System.create({ name: "targetTest/remove", input: one, target: "entity", components: { has: [tagged] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); +let amounts: number[] = []; +let changed: number[] = []; +const readAmounts = System.create({ name: "targetTest/readAmounts", observer: true, components: { read: [amount] } }, row => { amounts.push(row[amount]); }); +const watchChanged = System.create({ name: "targetTest/watchChanged", observer: true, components: { read: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); + +const inline = () => createWorld({ [workers]: 0 }); +const amountsOf = async (world: ReturnType): Promise => { + amounts = []; + await world.run(readAmounts); + return [...amounts].sort((a, b) => a - b); +}; + +describe("systems on one entity", () => { + it("run on the row of the entity their input names, writing only it", async () => { + await using world = inline(); + await world.run(spawn); + world.add(watchChanged); + // Its first pass sees every row as changed. + await world.run(watchChanged); + changed = []; + await world.run(bump, { entity: spawned[0] }); + expect(await amountsOf(world)).toEqual([2, 3, 11]); + // Its write marks that row alone changed. + expect(changed).toEqual([11]); + }); + + it("run on nothing for an entity that's gone or doesn't match, and issue commands for the one that does", async () => { + await using world = inline(); + await world.run(spawn); + await world.run(bump, { entity: spawned[2] }); + await world.run(bump, { entity: Entity.none }); + expect(await amountsOf(world)).toEqual([1, 2, 3]); + await world.run(remove, { entity: spawned[1] }); + expect(await amountsOf(world)).toEqual([1, 3]); + await world.run(bump, { entity: spawned[1] }); + expect(await amountsOf(world)).toEqual([1, 3]); + }); + + it("take their entity from an entity field of their input, and have components, no kernel, up, order or reduce", () => { + // @ts-expect-error its target isn't an entity field + expect(() => System.create({ name: "targetTest/notEntity", input: { type: "object", properties: { count: { type: "number" } } }, target: "count", components: { has: [amount] } }, () => {})) + .toThrow('System "targetTest/notEntity" runs on one entity, so it takes it from an input field "count" holding an entity.'); + // @ts-expect-error it needs components + expect(() => System.create({ name: "targetTest/noComponents", input: one, target: "entity" }, () => {})).toThrow('System "targetTest/noComponents" runs on one entity, so it needs components.'); + }); +}); diff --git a/packages/ecs/src/implementation/world/terminal.ts b/packages/ecs/src/implementation/world/terminal.ts new file mode 100644 index 000000000..3fbf4d8c6 --- /dev/null +++ b/packages/ecs/src/implementation/world/terminal.ts @@ -0,0 +1,13 @@ +/** + * The `Symbol.for` name of the global `WeakSet` of system bodies the plugin proved never reach their context's + * `commands`. Its version changes with the set's format. + */ +export const terminalBodiesName = "@adobe/ecs/terminal@1"; + +const terminalBodies = Symbol.for(terminalBodiesName); + +/** Whether the plugin proved a system's body never reaches its context's `commands`. */ +export const provenTerminal = (body: unknown): boolean => { + const set: unknown = Reflect.get(globalThis, terminalBodies); + return set instanceof WeakSet && typeof body === "function" && set.has(body); +}; diff --git a/packages/ecs/src/implementation/world/thread-directory.ts b/packages/ecs/src/implementation/world/thread-directory.ts new file mode 100644 index 000000000..7c7ec30d9 --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-directory.ts @@ -0,0 +1,210 @@ +import { declarationOf, firstKeyOf, keyOfId, labelOf } from '../../interface/registry/index.js'; +import { layoutOf, viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; +import { writeOf } from './archetype.js'; +import { emptyViews } from './arrays.js'; +import { tableLength } from './entities.js'; +import { + archetypeField, capacityField, cursorField, entitiesVersionField, entitiesWord, freeCapacityField, freeField, generationField, linearFlag, + recordCapacity, recordCount, recordEntities, recordFlags, recordStorages, recordVersion, recordWords, metaFlag, remoteFlag, resourceEntityWord, resourceFlag, + rowField, storageAdded, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, storageComponent, storageFlags, + storageWords, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, +} from './layout-directory.js'; +import { coveringBuffer, pageBytes, pagesWord } from './memory.js'; +import { createStringReader } from './strings.js'; +import type { Mirror, Mirrored } from './mirror.js'; +import type { Row, RowArchetype, RowHost, RowStorage } from './rows.js'; +import type { Key } from './state.js'; + +// A joined thread's view of the world's layout directory (threads.md, "Shared layout directory"): archetypes by their +// records, struct columns and ticks in shared memory, this thread's objects in its mirror, and the entity table, with +// the world's numbers for the keys its systems name. + +/** A joined thread's view of a storage of the world's, in shared memory, or of this thread's objects, in its mirror. */ +export type ThreadStorage = RowStorage & { readonly component: number }; + +/** A joined thread's view of one of the world's archetypes, by its record, as of its version and the mirror's columns. */ +export type ThreadArchetype = RowArchetype & { + readonly record: number; + readonly version: number; + readonly mirrored: Mirrored | null | undefined; + readonly components: readonly number[]; + readonly resource: boolean; + /** Whether it holds reports, which only queries that require a component see. */ + readonly meta: boolean; + readonly storageById: readonly (ThreadStorage | undefined)[]; +}; + +export type EntityViews = { cursor: Int32Array; free: Int32Array; generation: Uint32Array; archetype: Int32Array; row: Uint32Array; length: number }; + +/** A setup naming keys by the world's numbers, with their ids. */ +type Named = { readonly keys: readonly (readonly [number, string])[] }; + +const noTicks = new Float64Array(0); +const noTags = new Uint32Array(0); +const noValues: unknown[] = []; + +/** The memory as this thread last viewed it: its buffer, its words and its views, made again once it grew. */ +export type Viewed = { buffer: ArrayBufferLike; words: Uint32Array; views: Views }; + +/** + * A joined thread's directory reader over `memory`, its objects in `mirror` where it fills some: one per thread, + * shared by the systems the world places there and the observer systems it adds. + */ +export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror | null) => { + const viewed: Viewed = { buffer: new ArrayBuffer(0), words: new Uint32Array(0), views: emptyViews }; + let pages = -1; + /** By the world's number: what its key is here, and its layout, for the keys this thread's systems name. */ + const keys: (Key | undefined)[] = []; + const layouts: Layout[] = []; + const numbers = new Map(); + const archetypes: (ThreadArchetype | undefined)[] = []; + let entities: EntityViews | null = null; + let entitiesVersion = -1; + const rows: RowHost & { tick: number } = { layouts, rows: new Map(), tick: 0, records: true }; + const noMirrored: readonly (Mirrored | null | undefined)[] = []; + const thread = mirror?.thread ?? -1; + /** The records strings' columns name, which this thread reads. */ + const strings = createStringReader(memory); + + /** Views of the memory again, once it grew; every archetype's views with them. Returns its buffer. */ + const refresh = (): ArrayBufferLike => { + if (viewed.words.length === 0) viewed.words = new Uint32Array(memory.buffer); + const current = Atomics.load(viewed.words, pagesWord); + if (current === pages) return viewed.buffer; + const latest = coveringBuffer(memory, current * pageBytes); + if (latest.byteLength < current * pageBytes) throw new Error(`This thread's memory is ${latest.byteLength} bytes, short of the ${current * pageBytes} the world stored.`); + viewed.buffer = latest; + viewed.words = new Uint32Array(latest); + viewed.views = viewsOf(latest, 0, latest.byteLength); + pages = current; + archetypes.length = 0; + entitiesVersion = -1; + return latest; + }; + + const refreshEntities = (): EntityViews => { + const { words, buffer } = viewed; + const record = entitiesWord; + const version = Atomics.load(words, record + entitiesVersionField); + if (entities === null || version !== entitiesVersion) { + const capacity = words[record + capacityField]; + const views: EntityViews = { + cursor: new Int32Array(buffer, words[record + cursorField], 4), + free: new Int32Array(buffer, words[record + freeField], words[record + freeCapacityField]), + archetype: new Int32Array(buffer, words[record + archetypeField], capacity), + row: new Uint32Array(buffer, words[record + rowField], capacity), + generation: new Uint32Array(buffer, words[record + generationField], capacity), + length: 0, + }; + if (entities === null) entities = views; + // Spawners hold the table, so it's updated in place. + else Object.assign(entities, views); + entitiesVersion = version; + } + entities.length = entities.cursor[tableLength]; + return entities; + }; + + /** A storage's view, by its record's words; this thread's objects by their mirror column. */ + const storageOf = (base: number, capacity: number, mirrored: Mirrored | null | undefined): ThreadStorage | undefined => { + const { words, buffer } = viewed; + const component = words[base + storageComponent]; + const layout = layouts[component]; + if (layout === undefined) return undefined; + const flags = words[base + storageFlags]; + const ticked = (flags & tickedFlag) !== 0; + const own = (flags & remoteFlag) !== 0 && flags >>> threadShift === thread; + const chunks = words[base + storageChunks]; + const added = ticked ? new Float64Array(buffer, words[base + storageAdded], capacity) : noTicks; + const changed = ticked ? new Float64Array(buffer, words[base + storageChanged], capacity) : noTicks; + return { + component, + layout, + ...writeOf(layout), + views: (layout.kind === "linear" || layout.kind === "string") && (flags & linearFlag) !== 0 ? viewsOf(buffer, words[base + storageColumn], capacity * layout.stride) : emptyViews, + strings: layout.kind === "string" ? strings : null, + // Rows only this thread's systems write: a mirror's column, or one of its own while the mirror has none. + values: own ? mirrored?.byComponent[component]?.values ?? [] : noValues, + tags: own ? new Uint32Array(buffer, words[base + storageColumn], capacity) : noTags, + ticked, + added, + // A constant's `changed` ticks are its `added` ticks. + changed: layout.kind === "constant" ? added : changed, + chunkAdded: ticked ? new Float64Array(buffer, words[base + storageChunkAdded], chunks) : noTicks, + chunkChanged: ticked ? new Float64Array(buffer, words[layout.kind === "constant" ? base + storageChunkAdded : base + storageChunkChanged], chunks) : noTicks, + }; + }; + + const viewArchetype = (id: number, record: number, version: number, mirrored: Mirrored | null | undefined): ThreadArchetype => { + const { words, buffer } = viewed; + const capacity = words[record + recordCapacity]; + const storageById: (ThreadStorage | undefined)[] = []; + const components: number[] = []; + for (let s = 0; s < words[record + recordStorages]; s++) { + const base = record + recordWords + s * storageWords; + components.push(words[base + storageComponent]); + const storage = storageOf(base, capacity, mirrored); + if (storage !== undefined) storageById[storage.component] = storage; + } + return { + id, record, version, mirrored, components, storageById, + resource: (words[record + recordFlags] & resourceFlag) !== 0, + meta: (words[record + recordFlags] & metaFlag) !== 0, + entities: new Float64Array(buffer, words[record + recordEntities], capacity), + }; + }; + + /** An archetype's view: one load of its record's version, and new views only once it, or the mirror, moved on. */ + const archetypeAt = (id: number): ThreadArchetype | undefined => { + const { words } = viewed; + const cached = archetypes[id]; + const mirrored = (mirror?.archetypes ?? noMirrored)[id]; + if (cached !== undefined && cached.mirrored === mirrored && Atomics.load(words, cached.record + recordVersion) === cached.version) return cached; + const offset = cached?.record ?? (id < Atomics.load(words, tableLengthWord) ? words[(words[tableOffsetWord] >> 2) + id] >> 2 : 0); + if (offset === 0) return undefined; + return archetypes[id] = viewArchetype(id, offset, Atomics.load(words, offset + recordVersion), mirrored); + }; + + const countOf = (archetype: ThreadArchetype): number => viewed.words[archetype.record + recordCount]; + + /** The resource entity's archetype and row, or null before resources exist. */ + const resourceRow = (): { readonly archetype: ThreadArchetype; readonly row: number } | null => { + const word = Atomics.load(viewed.words, resourceEntityWord); + if (word === 0) return null; + const table = refreshEntities(); + const index = word - 1; + const archetype = archetypeAt(table.archetype[index]); + return archetype === undefined ? null : { archetype, row: table.row[index] }; + }; + + const tableCount = (): number => Atomics.load(viewed.words, tableLengthWord); + + /** Takes the world's numbers for the keys of systems or finalizers it places here. */ + const name = (setups: readonly Named[]): void => { + for (const { keys: named } of setups) { + for (const [number, id] of named) { + const key = keyOfId(id); + if (key === undefined || keys[number] !== undefined) continue; + keys[number] = key; + numbers.set(firstKeyOf(key), number); + layouts[number] = layoutOf(declarationOf(key)?.type); + // Views hold storages only of components with layouts here, so they're made again. + archetypes.length = 0; + } + } + }; + + const numberOf = (key: Key): number => { + const number = numbers.get(firstKeyOf(key)); + if (number === undefined) throw new Error(`"${labelOf(key)}" has no number from the world here.`); + return number; + }; + + return { + viewed, rows, keys, layouts, numbers, mirror, strings, + refresh, refreshEntities, archetypeAt, countOf, resourceRow, tableCount, name, numberOf, + layoutOf: (key: Key): Layout | undefined => layouts[numbers.get(firstKeyOf(key)) ?? -1], + }; +}; + +export type ThreadDirectory = ReturnType; diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts new file mode 100644 index 000000000..d0dabd7db --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -0,0 +1,318 @@ +import type { Leaf } from '../../interface/finalizer/created.js'; +import { labelOf } from '../../interface/registry/index.js'; +import { objectLayout, viewsOf, type Layout } from '../../interface/registry/layout.js'; +import { writeOf } from './archetype.js'; +import { emptyViews } from './arrays.js'; +import { readCell } from './cells.js'; +import { logFinalizedWord, logStopsWord, nullTag, ownTag, slotTags, startTag } from './move-log.js'; +import { stagedValue, type Mirror, type Previously } from './mirror.js'; +import type { WorkerError } from './protocol.js'; +import { rowBinderOf, type Row, type RowArchetype, type RowHost, type RowStorage } from './rows.js'; +import { + archetypesAt, blockGroupsWord, blockHeaderBytes, blockNextWord, blockSyncWord, columnBytes, entitiesAt, groupBytesWord, groupCountWord, + groupIndexWord, rowsAt, tagsAt, valuesAt, +} from './stop-lists.js'; +import type { Key } from './state.js'; +import type { StringStore } from './strings.js'; + +// A joined thread's finalizers (threads.md, "Finalizers on other threads"): as it replays each sync, before that sync's +// entries apply, it runs them for the sync's stop list, reading the linear structs the world copied there and its own +// objects at the rows the stops name, then stores the sync it finalized through. + +/** A storage of a joined thread's view, as its finalizers' rows read it. */ +type FinalizedStorage = RowStorage & { readonly component: number }; +/** An archetype of a joined thread's view, as its systems see it. */ +type FinalizedArchetype = RowArchetype & { readonly components: readonly number[]; readonly resource: boolean; readonly meta: boolean; readonly storageById: readonly (FinalizedStorage | undefined)[] }; + +/** What a joined thread gives its finalizers. */ +export type ThreadFinalizersHost = { + readonly mirror: Mirror; + /** Views of the memory, again once it grew. */ + readonly refresh: () => ArrayBufferLike; + readonly rows: RowHost; + readonly numberOf: (key: Key) => number; + readonly layoutOf: (key: Key) => Layout | undefined; + readonly archetypeAt: (id: number) => A | undefined; + readonly countOf: (archetype: A) => number; + readonly tableCount: () => number; + readonly resourceRow: () => { readonly archetype: A; readonly row: number } | null; + /** The records strings' columns name: a stop list's copies, for its strings. */ + readonly strings: StringStore; + /** Posts what finalizers threw, by their index here, for the world's next report. */ + readonly failed: (errors: readonly (readonly [number, WorkerError])[]) => void; +}; + +const errorOf = (error: unknown): WorkerError => + error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : { name: "Error", message: String(error) }; + +const noTicks = new Float64Array(0); +const noTags = new Uint32Array(0); + +/** One finalizer here: what it matches and reads, its row binder, and its context. */ +type ThreadFinalizer = { + readonly leaf: Leaf; + readonly required: readonly number[]; + readonly excluded: readonly number[]; + /** Its reads by component: objects, with their starts, linear structs, with their layouts, and constants. */ + readonly objects: readonly number[]; + readonly starts: readonly unknown[]; + readonly linear: readonly number[]; + readonly layouts: readonly Layout[]; + readonly constants: readonly number[]; + readonly constantLayouts: readonly Layout[]; + readonly bind: (archetype: RowArchetype) => Row; + readonly context: object; + /** Resolves its resources' cells, as of now. */ + readonly cells: () => void; + readonly body: (row: object, context: object) => void; +}; + +/** A storage a stop list's column holds, for a row over its stops. */ +const columnStorage = (component: number, layout: Layout, views: RowStorage["views"], values: unknown[], strings: StringStore | null): FinalizedStorage => ({ + component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), ticked: false, added: noTicks, changed: noTicks, chunkAdded: noTicks, chunkChanged: noTicks, tags: noTags, +}); + +/** + * The finalizers the world places on this thread, with what they run against: stop lists as the thread replays them, + * and every row they match once the world is disposed. + */ +export const createThreadFinalizers = (host: ThreadFinalizersHost) => { + const { mirror } = host; + const finalizers: (ThreadFinalizer | null | undefined)[] = []; + /** The newest block run, which the next block's offset is read from; 0 before the first. */ + let anchor = 0; + /** The sync finalized through, as stored. */ + let finalized = 0; + let buffer: ArrayBufferLike = new ArrayBuffer(0); + let u32: Uint32Array = new Uint32Array(0); + let f64: Float64Array = new Float64Array(0); + const errors: [number, WorkerError][] = []; + + const views = (): void => { + const latest = host.refresh(); + if (latest === buffer && u32.length > 0) return; + buffer = latest; + u32 = new Uint32Array(latest); + f64 = new Float64Array(latest, 0, latest.byteLength >> 3); + }; + + const create = (leaf: Leaf): ThreadFinalizer => { + const { components, resources } = leaf.declaration; + const reads = components.read ?? []; + const objects: number[] = []; + const starts: unknown[] = []; + const linear: number[] = []; + const layouts: Layout[] = []; + const constants: number[] = []; + const constantLayouts: Layout[] = []; + for (const key of reads) { + const component = host.numberOf(key); + const layout = host.layoutOf(key); + if (layout === undefined) throw new Error(`"${labelOf(key)}" has no layout here.`); + if (layout.kind === "object") { + objects.push(component); + starts.push(mirror.starts.get(component) ?? null); + } + // A string's stop column holds handles of records the world copied for it. + else if (layout.kind === "linear" || layout.kind === "string") { + linear.push(component); + layouts.push(layout); + } + else { + constants.push(component); + constantLayouts.push(layout); + } + } + const value: object = Object.create({}); + const keys = [...new Set(resources?.read ?? [])]; + const numbers = keys.map(host.numberOf); + const resolved: { storage: FinalizedStorage | undefined; row: number }[] = keys.map(() => ({ storage: undefined, row: 0 })); + if (keys.length > 0) { + const values: object = Object.create({}); + keys.forEach((key, k) => { + Object.defineProperty(values, key, { + get: () => { + const { storage, row } = resolved[k]; + if (storage === undefined) throw new Error(`Resource "${labelOf(key)}" isn't in this world.`); + return readCell(storage, row); + }, + enumerable: true, + }); + }); + Object.defineProperty(value, "resources", { value: values, enumerable: true }); + } + // Method parameters are bivariant, so the runtime can see any finalizer's body with erased parameter types. + const untyped: { body(row: object, context: object): void } = leaf; + return { + leaf, objects, starts, linear, layouts, constants, constantLayouts, + required: [...new Set([...reads, ...components.has ?? []])].map(host.numberOf), + excluded: (components.not ?? []).map(host.numberOf), + bind: rowBinderOf(host.rows, host.numberOf, reads, []), + context: value, + cells: () => { + const found = keys.length === 0 ? null : host.resourceRow(); + for (let k = 0; k < keys.length; k++) { + resolved[k].storage = found?.archetype.storageById[numbers[k]]; + resolved[k].row = found?.row ?? 0; + } + }, + body: (row, context) => untyped.body(row, context), + }; + }; + + /** Records the finalizers the world places here; one this thread never imported fails each group of its stops. */ + const setup = (placed: readonly { readonly index: number; readonly leaf: Leaf | null }[]): void => { + for (const { index, leaf } of placed) finalizers[index] = leaf === null ? null : create(leaf); + }; + + /** Runs a body, keeping what it throws. */ + const guarded = (index: number, finalizer: ThreadFinalizer, row: Row): void => { + try { + finalizer.body(row.value, finalizer.context); + } + catch (error) { + errors.push([index, errorOf(error)]); + } + }; + + /** One of an object read's values for a stop: by its tag, else at its row, or after a rebuild as it was. */ + const objectOf = (tag: number, start: unknown, entity: number, archetype: number, row: number, component: number, old: Previously | null): unknown => { + if (tag === startTag) return start; + if (tag === nullTag) return null; + if (old === null) { + const column = mirror.archetypes[archetype]?.byComponent[component]; + return column === undefined ? start : column.values[row]; + } + const previous = old(entity, component, start); + return tag === ownTag ? previous : stagedValue(mirror, (tag - slotTags) >>> 0, previous); + }; + + /** Whether every finalizer a block names was set up here, as null for one this thread never imported. */ + const known = (block: number): boolean => { + const groups = u32[(block >> 2) + blockGroupsWord]; + let group = block + blockHeaderBytes; + for (let g = 0; g < groups; g++) { + if (finalizers[u32[(group >> 2) + groupIndexWord]] === undefined) return false; + group += u32[(group >> 2) + groupBytesWord]; + } + return true; + }; + + /** Runs each group of a block: the bodies of its finalizer for its stops, over a row reading the block's columns. */ + const runBlock = (block: number, old: Previously | null): void => { + const groups = u32[(block >> 2) + blockGroupsWord]; + let group = block + blockHeaderBytes; + for (let g = 0; g < groups; g++) { + const index = u32[(group >> 2) + groupIndexWord]; + const count = u32[(group >> 2) + groupCountWord]; + const bytes = u32[(group >> 2) + groupBytesWord]; + const finalizer = finalizers[index]; + if (finalizer === null || finalizer === undefined) { + // Set up, as null, for one this thread never imported. + errors.push([index, errorOf(new Error(`A finalizer the world placed on this thread isn't one this thread imported, so ${count === 1 ? "a stop" : `${count} stops`} didn't run.`))]); + group += bytes; + continue; + } + const storageById: FinalizedStorage[] = []; + const entityWords = entitiesAt(group) >> 3; + const archetypeWords = archetypesAt(group, count) >> 2; + const rowWords = rowsAt(group, count) >> 2; + const { objects, starts, linear, layouts } = finalizer; + for (let k = 0; k < objects.length; k++) { + const tags = tagsAt(group, count, k) >> 2; + const values: unknown[] = new Array(count); + for (let i = 0; i < count; i++) { + values[i] = objectOf(u32[tags + i], starts[k], f64[entityWords + i], u32[archetypeWords + i], u32[rowWords + i], objects[k], old); + } + storageById[objects[k]] = columnStorage(objects[k], objectLayout, emptyViews, values, null); + } + let column = valuesAt(group, count, objects.length); + for (let k = 0; k < linear.length; k++) { + const layout = layouts[k]; + const stride = layout.kind === "linear" || layout.kind === "string" ? layout.stride : 0; + storageById[linear[k]] = columnStorage(linear[k], layout, viewsOf(buffer, column, count * stride), [], host.strings); + column += columnBytes(count, stride); + } + for (let k = 0; k < finalizer.constants.length; k++) storageById[finalizer.constants[k]] = columnStorage(finalizer.constants[k], finalizer.constantLayouts[k], emptyViews, [], null); + const row = finalizer.bind({ id: -1, entities: new Float64Array(buffer, entitiesAt(group), count), storageById }); + finalizer.cells(); + for (let i = 0; i < count; i++) { + row.index = i; + guarded(index, finalizer, row); + } + group += bytes; + } + }; + + const report = (): void => { + if (errors.length === 0) return; + host.failed(errors.splice(0)); + }; + + /** Stores the sync finalized through, once it moved on. */ + const acknowledge = (sync: number): void => { + if (((sync - finalized) | 0) <= 0) return; + finalized = sync >>> 0; + Atomics.store(mirror.header, logFinalizedWord, finalized); + }; + + /** + * Runs the stop lists of every sync through `sync` not run yet, in order, then stores it as finalized through: with + * `old`, after the log lapped, reading this thread's objects as they were before the rebuild. False, having stopped + * before it, at a block naming a finalizer whose setup hasn't arrived yet. + */ + const through = (sync: number, old: Previously | null): boolean => { + views(); + const { header } = mirror; + let complete = true; + for (;;) { + const next = anchor === 0 ? Atomics.load(header, logStopsWord) >>> 0 : Atomics.load(u32, (anchor >> 2) + blockNextWord); + if (next === 0) break; + const blockSync = u32[(next >> 2) + blockSyncWord]; + if (((sync - blockSync) | 0) < 0) break; + if (!known(next)) { + acknowledge((blockSync - 1) >>> 0); + complete = false; + break; + } + runBlock(next, old); + anchor = next; + } + if (complete) acknowledge(sync); + report(); + return complete; + }; + + /** Whether a stop list is linked that this thread hasn't run. */ + const pending = (): boolean => { + if (anchor === 0) return Atomics.load(mirror.header, logStopsWord) !== 0; + views(); + return Atomics.load(u32, (anchor >> 2) + blockNextWord) !== 0; + }; + + /** Once the world is disposed: every finalizer runs for every row it matches, reading this thread's objects there. */ + const all = (): void => { + views(); + for (let index = 0; index < finalizers.length; index++) { + const finalizer = finalizers[index]; + if (finalizer === null || finalizer === undefined) continue; + finalizer.cells(); + for (let id = 0; id < host.tableCount(); id++) { + const archetype = host.archetypeAt(id); + if (archetype === undefined || archetype.resource || (archetype.meta && finalizer.required.length === 0)) continue; + const { components } = archetype; + if (!finalizer.required.every(component => components.includes(component)) || finalizer.excluded.some(component => components.includes(component))) continue; + const count = host.countOf(archetype); + if (count === 0) continue; + const row = finalizer.bind(archetype); + for (let i = 0; i < count; i++) { + row.index = i; + guarded(index, finalizer, row); + } + } + } + report(); + }; + + return { setup, through, pending, all }; +}; diff --git a/packages/ecs/src/implementation/world/thread-indexes.ts b/packages/ecs/src/implementation/world/thread-indexes.ts new file mode 100644 index 000000000..6d81c6a53 --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-indexes.ts @@ -0,0 +1,90 @@ +import type { Layout } from '../../interface/registry/layout.js'; +import { recordKey, type HashView } from './index-hash.js'; +import { keyPartsOf } from './index-keys.js'; +import { lookupFunction, type IndexView, type LookupArchetype, type Since } from './index-lookups.js'; +import { sortedStride, type SortedView } from './index-sorted.js'; +import type { RowHost } from './rows.js'; +import type { Key } from './state.js'; + +/** How many f64 words a lookup's index record takes, as `writeIndexParameters` (`kernel-indexes.ts`) writes it. */ +export const recordWordsOf = (sorted: boolean, watched: number): number => (sorted ? 11 : 7) + watched; + +/** + * A joined thread's lookup through one of the world's indexes, which lives in the memory: the function its body calls, + * and `refresh`, which points its views at the index's arrays as the world wrote them into its record before the task. + */ +export type ThreadIndex = { readonly lookup: (...key: unknown[]) => unknown; readonly refresh: (buffer: ArrayBufferLike) => void }; + +/** What a joined thread knows of a lookup through an index: its key's layouts, the index's kind, its rows' reads and its record. */ +export type ThreadIndexSetup = { + readonly layouts: readonly Layout[]; + readonly sorted: boolean; + readonly unique: boolean; + readonly reads: readonly Key[]; + readonly readIds: readonly number[]; + readonly watched: number; + /** The byte offset of the record the world writes before each task. */ + readonly record: number; +}; + +const none32 = new Uint32Array(0); +const noneI32 = new Int32Array(0); +const none64 = new Float64Array(0); + +/** A view from `at` to the end of the memory, the same one while neither changed. */ +const spanning = (current: A, buffer: ArrayBufferLike, at: number, make: (buffer: ArrayBufferLike, at: number) => A): A => + current.buffer === buffer && current.byteOffset === at ? current : make(buffer, at); +const u32From = (buffer: ArrayBufferLike, at: number): Uint32Array => new Uint32Array(buffer, at); +const i32From = (buffer: ArrayBufferLike, at: number): Int32Array => new Int32Array(buffer, at); +const f64From = (buffer: ArrayBufferLike, at: number): Float64Array => new Float64Array(buffer, at); + +export const createThreadIndex = (rows: RowHost, archetypeAt: (id: number) => LookupArchetype | undefined, setup: ThreadIndexSetup): ThreadIndex => { + const { parts, words } = keyPartsOf(setup.layouts); + const watchedPositions = Array.from({ length: setup.watched }, (_, k) => k); + const hashWatched = new Map(); + const sortedWatched = new Map(); + const hash: HashView | null = setup.sorted ? null : { words, stride: recordKey + words, slots: none32, records: none32, ticks: none64, cleared: 0, entries: noneI32, watched: hashWatched }; + const sorted: SortedView | null = !setup.sorted ? null : { + words, stride: sortedStride(words), parts, objects: false, entries: none32, lengths: noneI32, order: noneI32, blocks: 0, counts: noneI32, tree: none64, leaves: 1, + fences: none32, size: 0, cleared: 0, watched: sortedWatched, + }; + const index: IndexView = { parts, words, unique: setup.unique, hash, sorted }; + const since: Since = { value: 0 }; + const lookup = lookupFunction({ rows, archetypeAt, index, unique: setup.unique, reads: setup.reads, readIds: setup.readIds, watched: watchedPositions, since, replaced: null }); + const count = recordWordsOf(setup.sorted, setup.watched); + let record: Float64Array = none64; + const refresh = (buffer: ArrayBufferLike): void => { + if (record.buffer !== buffer) record = new Float64Array(buffer, setup.record, count); + if (hash !== null) { + const slots = record[0]; + const length = (record[1] + 1) * 2; + if (hash.slots.buffer !== buffer || hash.slots.byteOffset !== slots || hash.slots.length !== length) hash.slots = new Uint32Array(buffer, slots, length); + hash.records = spanning(hash.records, buffer, record[2], u32From); + hash.entries = spanning(hash.entries, buffer, record[3], i32From); + hash.ticks = spanning(hash.ticks, buffer, record[4], f64From); + hash.cleared = record[5]; + since.value = record[6]; + for (let k = 0; k < setup.watched; k++) hashWatched.set(k, spanning(hashWatched.get(k) ?? none64, buffer, record[7 + k], f64From)); + return; + } + if (sorted === null) return; + sorted.fences = spanning(sorted.fences, buffer, record[0], u32From); + sorted.blocks = record[1]; + sorted.order = spanning(sorted.order, buffer, record[2], i32From); + sorted.lengths = spanning(sorted.lengths, buffer, record[3], i32From); + sorted.entries = spanning(sorted.entries, buffer, record[4], u32From); + sorted.counts = spanning(sorted.counts, buffer, record[5], i32From); + sorted.size = record[6]; + sorted.tree = spanning(sorted.tree, buffer, record[7], f64From); + sorted.leaves = record[8]; + sorted.cleared = record[9]; + since.value = record[10]; + for (let k = 0; k < setup.watched; k++) { + const watch = sortedWatched.get(k); + const at = record[11 + k]; + if (watch === undefined) sortedWatched.set(k, { tree: f64From(buffer, at) }); + else watch.tree = spanning(watch.tree, buffer, at, f64From); + } + }; + return { lookup, refresh }; +}; diff --git a/packages/ecs/src/implementation/world/thread-observer.ts b/packages/ecs/src/implementation/world/thread-observer.ts new file mode 100644 index 000000000..3794853f4 --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-observer.ts @@ -0,0 +1,234 @@ +import type { Commands } from '../../interface/system/commands.js'; +import type { Leaf } from '../../interface/system/leaf.js'; +import { definitionOf, isLeaf, itemOfId, labelOfItem } from '../../interface/registry/index.js'; +import { cancelled, notRun, opening, outcomeWord, postedWord, removedWord, runsWord, observerBlockWords, syncWord, tickSlot } from './joined-observer.js'; +import { protocolName, type ObserverSetup, type ToWorld } from './joining.js'; +import type { Mirror } from './mirror.js'; +import type { WorkerError } from './protocol.js'; +import { doneWord, endWord, progressWord, tailChunk } from './tail-blocks.js'; +import type { ThreadDirectory } from './thread-directory.js'; +import { createThreadRunner, type RunnerHost, type ThreadRunner } from './thread-systems.js'; + +// A joined thread's observer systems (threads.md, "Observer systems on joined threads"): the world posts each into its +// block after a sync point; this thread claims a post, as a tail claims its rows, reading the shared memory as of that +// sync point, or the copy a later writer took, and runs the system once. + +/** What the thread gives its observer systems. */ +export type ThreadObserverHost = { + readonly directory: ThreadDirectory; + readonly mirror: Mirror | null; + readonly post: (message: ToWorld) => void; + /** Brings the mirror up to the newest published sync; false while it can't yet. */ + readonly current: () => boolean; + /** This thread's own observer system by its number, while it lives. */ + readonly localOf: (number: number) => Local | undefined; +}; + +/** + * An observer system of this thread's own, by its number for it: its value, its runner once the world placed it, and + * the newest of its runs a post ran for. + */ +export type Local = { + readonly number: number; + readonly leaf: Leaf; + runner: ThreadRunner | null; + ran: number; + /** Whether it's placed on this thread now: false once removed, until placed again. */ + placed: boolean; + /** The last of this thread's runs posted before it was last removed here: those that didn't run then never do. */ + stopped: number; +}; + +/** A block the world posts an observer system into: its views, and the last post of it this thread handled. */ +type Block = { readonly control: Int32Array; readonly ticks: Float64Array; handled: number }; + +/** + * An observer system the world placed here: its blocks' views, two for one reading only struct resources, whose posts + * hold copies of what they read, and its runner or local system. + */ +type Placed = { + readonly index: number; + readonly label: string; + readonly blocks: readonly Block[]; + /** Whether its posts read only their blocks, never this thread's objects. */ + readonly contained: boolean; + /** One the world names by id, which lives as long as its module. */ + readonly runner: ThreadRunner | null; + /** One of this thread's own, kept only while it lives. */ + readonly local: WeakRef | null; + stopped: boolean; +}; + +const errorOf = (error: unknown): WorkerError => + error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : { name: "Error", message: String(error) }; + +const refuse = (): never => { throw new Error("An observer system issues no commands."); }; +/** An observer system's context has no commands, so these are never called. */ +const noCommands: Commands = { spawn: refuse, despawn: refuse, add: refuse, remove: refuse }; +const noStamps: number[] = []; + +export const createThreadObserver = (host: ThreadObserverHost) => { + const { directory, mirror } = host; + const runnerHost: RunnerHost = { + rows: directory.rows, archetypeAt: directory.archetypeAt, countOf: directory.countOf, tableCount: directory.tableCount, + resourceRow: directory.resourceRow, refreshEntities: directory.refreshEntities, commands: noCommands, + numberOf: directory.numberOf, layoutOf: directory.layoutOf, mirror, refresh: directory.refresh, strings: directory.strings, + }; + const placed: (Placed | undefined)[] = []; + + /** Records observer systems the world places here; one by an id this thread never imported doesn't run. */ + const setup = (systems: readonly ObserverSetup[]): void => { + directory.name(systems); + const buffer = directory.refresh(); + for (const { index, local, id, block, spare, indexes } of systems) { + const records = new Map(indexes); + const blocks = (spare === null ? [block] : [block, spare]).map((at): Block => ({ + control: new Int32Array(buffer, at, observerBlockWords), ticks: new Float64Array(buffer, at + tickSlot * 8, 2), handled: 0, + })); + const contained = spare !== null; + if (local !== null) { + const own = host.localOf(local); + if (own === undefined) continue; + own.runner = createThreadRunner(runnerHost, own.leaf, records); + own.placed = true; + placed[index] = { index, label: labelOfItem(own.leaf), blocks, contained, runner: null, local: new WeakRef(own), stopped: false }; + continue; + } + const item = id === null ? undefined : itemOfId(id); + const definition = item === undefined ? undefined : definitionOf(item); + const leaf = definition !== undefined && isLeaf(definition) ? definition : null; + placed[index] = { index, label: leaf === null ? String(id) : labelOfItem(leaf), blocks, contained, runner: leaf === null ? null : createThreadRunner(runnerHost, leaf, records), local: null, stopped: false }; + } + }; + + /** The world removed observer systems here: dropped, and the world told. */ + const unplace = (indexes: readonly number[]): void => { + for (const index of indexes) { + const entry = placed[index]; + if (entry === undefined) continue; + entry.stopped = true; + entry.runner?.halt(); + const own = entry.local?.deref(); + if (own !== undefined && own.runner !== null && placed[index] === entry) { + own.placed = false; + } + placed[index] = undefined; + } + host.post({ protocol: protocolName, kind: "observer dropped", indexes }); + }; + + /** Stops this thread's own system at once, as it's removed here: no row of it runs from now on. */ + const stop = (local: Local): void => { + local.runner?.halt(); + local.runner = null; + local.placed = false; + for (const entry of placed) if (entry !== undefined && entry.local?.deref() === local) entry.stopped = true; + }; + + /** Settles a claimed post having run nothing. */ + const finishUnrun = (control: Int32Array): void => { + Atomics.store(control, outcomeWord, notRun); + Atomics.store(control, doneWord, Atomics.load(control, endWord)); + Atomics.notify(control, doneWord); + }; + + /** Gives back a claim it can't run yet, owed to a run: run nothing, and the world posts it again between runs. */ + const giveBack = (control: Int32Array): void => { + finishUnrun(control); + if (Atomics.load(control, runsWord) > 0) host.post({ protocol: protocolName, kind: "observer again" }); + }; + + /** + * Runs a new post in a block of one placed here, if it has one and the world hasn't cancelled it; returns whether + * it did, or the errors so far with what it threw. + */ + const runPost = (entry: Placed, block: Block, errors: [number, WorkerError][] | null): [number, WorkerError][] | null | true | false => { + const { control } = block; + // The post seen: one a claim finds cancelled is this one, as a later one is written only once it's cancelled. + const seen = Atomics.load(control, postedWord); + if (seen === block.handled) return false; + const own = entry.local?.deref(); + const runner = entry.runner ?? own?.runner ?? null; + if (entry.stopped || runner === null || Atomics.load(control, removedWord) !== 0) { + // Run as nothing, so the world, which may wait for it, settles it. + const claimed = Atomics.add(control, progressWord, tailChunk); + // One the world is writing is claimed once it's open. + if (claimed >= opening) return false; + if (claimed >= cancelled) { + block.handled = seen; + return false; + } + // Claimed, the post in the block stays until it's done, so its posted word is this one's. + block.handled = Atomics.load(control, postedWord); + finishUnrun(control); + return false; + } + // This thread's objects are read as of the post's sync: replayed through the newest first. + const bounded = mirror !== null && !entry.contained; + if (bounded && !host.current()) return false; + const claimed = Atomics.add(control, progressWord, tailChunk); + // One the world is writing is claimed once it's open. Claimed now, the post stays as it is until it's done, so + // the posted word read after the claim is its own. + if (claimed >= opening) return false; + // Cancelled, it's the post seen: one written after it, whose posted word may already have moved, is claimed + // next. + if (claimed >= cancelled) { + block.handled = seen; + return false; + } + block.handled = Atomics.load(control, postedWord); + const sync = Atomics.load(control, syncWord) >>> 0; + if (bounded && mirror.through !== sync && (!host.current() || mirror.through !== sync)) { + giveBack(control); + return false; + } + // The world's memory may have grown since the last post: its views cover it first. + directory.refresh(); + directory.rows.tick = block.ticks[0]; + // Read first: the world may write its next post here once this one says what it did. + const runs = Atomics.load(control, runsWord); + const outcome = runner.runTail(block.ticks[1], undefined, control, sync, 0, noStamps, claimed, outcomeWord); + if (own !== undefined && runs > 0) own.ran = Math.max(own.ran, runs); + if (outcome.error !== undefined) { + const found = errors ?? []; + found.push([entry.index, errorOf(outcome.error)]); + return found; + } + return true; + }; + + /** + * Runs every new post here; returns how many ran. What one threw goes to the world, for its next report. + * Of a system's two blocks, only one holds a post it hasn't claimed, the newest. + */ + const process = (): number => { + let ran = 0; + let errors: [number, WorkerError][] | null = null; + for (let k = 0; k < placed.length; k++) { + const entry = placed[k]; + if (entry === undefined) continue; + for (let b = 0; b < entry.blocks.length; b++) { + const result = runPost(entry, entry.blocks[b], errors); + if (result === false) continue; + ran++; + if (result !== true) errors = result; + } + } + if (errors !== null && errors.length > 0) host.post({ protocol: protocolName, kind: "observer failed", errors }); + return ran; + }; + + return { + setup, + unplace, + stop, + process, + /** Whether any observer system is placed here. */ + active: (): boolean => placed.some(entry => entry !== undefined), + /** For tests of what a thread holds: how many observer systems are placed here. */ + placedCount: (): number => placed.filter(entry => entry !== undefined).length, + }; +}; + +/** Whether a post ran for run `sequence` of a local observer system, or none ever will, since it was removed first. */ +export const settledFor = (local: Local, sequence: number): boolean => local.ran >= sequence || local.stopped >= sequence; diff --git a/packages/ecs/src/implementation/world/thread-systems-worker.fixture.ts b/packages/ecs/src/implementation/world/thread-systems-worker.fixture.ts new file mode 100644 index 000000000..b7ebe345f --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-systems-worker.fixture.ts @@ -0,0 +1,70 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import type { World } from "../../interface/world/world.js"; +import { asEntity } from "./entities.js"; +import { join } from "./create.js"; +import { scopeChannel } from "./port.js"; +import { idle } from "./joining.test-helper.js"; +import { objectsThere, ranHere } from "./thread-systems.test-helper.js"; + +/** + * A thread joining a world through the port it's sent, claiming "threadSystemsTest", whose systems it imports: it reads + * its objects when asked, spins without yielding, tells how late timers it queued ran, or tells why the world refused it. + */ + +type Port = MessagePort | NodeMessagePort; +export type ToThreadSystemsWorker = + | { readonly kind: "join"; readonly port: Port } + | { readonly kind: "objects"; readonly entities: readonly number[] } + | { readonly kind: "spin" } + | { readonly kind: "timers" } + | { readonly kind: "refused"; readonly port: Port }; +export type FromThreadSystemsWorker = + | { readonly kind: "joined" } + | { readonly kind: "objects"; readonly objects: readonly { readonly [name: string]: unknown }[] } + | { readonly kind: "spinning" } + | { readonly kind: "timers queued" } + | { readonly kind: "timers fired"; readonly late: number; readonly runs: number } + | { readonly kind: "refused"; readonly message: string }; + +const channel = scopeChannel(); + +let remote: World | null = null; +channel.onMessage(async message => { + if ("protocol" in message) return; + if (message.kind === "join") { + remote = join(message.port, { threads: ["threadSystemsTest"] }); + channel.post({ kind: "joined" }); + } + else if (message.kind === "objects") { + const objects = remote === null ? [] : await objectsThere(remote)(message.entities.map(asEntity)); + channel.post({ kind: "objects", objects }); + } + else if (message.kind === "timers") { + // Timers, one after another, each set by a run of `counted` and running only once this thread returns to its + // event loop: the latest any ran, and the most runs there once one was due before it ran. + let late = 0; + let runs = 0; + const next = (left: number): void => { + ranHere.queued = () => { + late = Math.max(late, performance.now() - ranHere.due); + runs = Math.max(runs, ranHere.atDue < 0 ? 0 : ranHere.runs - ranHere.atDue + 1); + ranHere.due = Infinity; + if (left > 1) next(left - 1); + else channel.post({ kind: "timers fired", late, runs }); + }; + }; + next(40); + channel.post({ kind: "timers queued" }); + } + else if (message.kind === "spin") { + channel.post({ kind: "spinning" }); + // Never yields again, so nothing here runs: no task, no observer. + setTimeout(() => { for (;;) { /* never yields */ } }, 0); + } + else { + const refused = join(message.port, { threads: ["threadSystemsTest"] }); + const ran = await refused.run(idle).then(() => "ran", (error: unknown) => error instanceof Error ? error.message : String(error)); + await refused.dispose(); + channel.post({ kind: "refused", message: ran }); + } +}); diff --git a/packages/ecs/src/implementation/world/thread-systems.cases.ts b/packages/ecs/src/implementation/world/thread-systems.cases.ts new file mode 100644 index 000000000..637bb4e23 --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-systems.cases.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./thread-systems.fixture.js"; + +/** Systems on a joined worker: the cases, over the fixture built for node or from source. */ +export const threadSystemsCases = (load: () => Promise) => describe("systems on a joined worker", () => { + it("give an inline world's results: rows, commands, spawns, reductions, resources, lookups, filters, conditions and failures", async () => { + const fixture = await load(); + const { expected, actual } = await fixture.sameResults(); + expect(actual.snapshots).toEqual(expected.snapshots); + expect(actual.reports).toEqual(expected.reports); + expect(actual.warnings[1].length).toBe(7); + }, 30000); + it("are skipped with a \"thread\" failure when the worker dies mid-task, and run on the world's thread once it's gone", async () => { + const fixture = await load(); + expect(await fixture.diesMidTask()).toEqual({ + report: { failures: [["thread", "threadSystemsTest/stall", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']], skipped: ["threadSystemsTest/stall"] }, + after: 0, + }); + }, 30000); + it("can't join claiming the thread of a system the joined thread never imported", async () => { + const fixture = await load(); + expect(await fixture.unimported()).toBe('System "threadSystemsTest/onlyHere" runs on thread "threadSystemsTest", which the joined thread claiming it never imported.'); + }, 30000); + it("don't stall ticks that don't touch their thread when it never yields", async () => { + const fixture = await load(); + expect(await fixture.neverYields(200)).toBeGreaterThanOrEqual(200); + }, 30000); + it("let the joined worker return to its event loop while the world ticks back to back", async () => { + const fixture = await load(); + const { runs } = await fixture.timerWhileTicking(); + // It returns once a tick, each tick a task here, so a timer due waits a few at most, as the event loop's task + // queues take turns; without returning, hundreds. + expect(runs).not.toBeNull(); + expect(runs).toBeLessThanOrEqual(10); + }, 30000); + it("keep waking the joined worker for tasks that follow each other closely", async () => { + const fixture = await load(); + // Ticks that stopped for 5 s give -1; ones going on slowly, as on a busy machine, are waited for. + expect(await fixture.ticksThere(30000)).toBeGreaterThanOrEqual(30000); + }, 120000); +}); diff --git a/packages/ecs/src/implementation/world/thread-systems.fixture.ts b/packages/ecs/src/implementation/world/thread-systems.fixture.ts new file mode 100644 index 000000000..23d3e11ce --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-systems.fixture.ts @@ -0,0 +1,205 @@ +/// +import { Observe } from "../../observe/index.js"; +import { always } from "../../interface/resources/always.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import { backToBack } from "./loop.js"; +import type { FromThreadSystemsWorker, ToThreadSystemsWorker } from "./thread-systems-worker.fixture.js"; +import { caption, changes, count, countThere, counted, scenario, stall } from "./thread-systems.test-helper.js"; +import { reportOf, runReported } from "./reports.test-helper.js"; + +/** + * Scenarios of systems on a joined worker, run built in node and from source in chromium: the same runs as an inline + * world's, a worker that dies mid-task, one that never imported a system placed on it, and one that never yields. + */ + +/** A system on the worker's thread, which only this module declares, so the worker never imported it. */ +const onlyHere = System.create({ name: "threadSystemsTest/onlyHere", components: { read: [caption] } }, row => { void row[caption]; }); + +type Worker = { + readonly ask: (message: ToThreadSystemsWorker, transfer?: readonly unknown[]) => Promise; + /** The worker's next reply, asking nothing. */ + readonly next: () => Promise; + readonly terminate: () => Promise; +}; + +const workerFile = "./thread-systems-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./thread-systems-worker.fixture.js", import.meta.url), { type: "module" }); + const next = (): Promise => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => { if (!("protocol" in event.data)) resolve(event.data); }; + }); + return { + ask: (message, transfer = []) => { + const reply = next(); + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + return reply; + }, + next, + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + const next = (): Promise => new Promise(resolve => { + const reply = (data: FromThreadSystemsWorker): void => { + if ("protocol" in data) return; + worker.off("message", reply); + resolve(data); + }; + worker.on("message", reply); + }); + return { + ask: (message, transfer = []) => { + const reply = next(); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + return reply; + }, + next, + terminate: async () => { await worker.terminate(); }, + }; +}; + +/** The first value an observer gives that `accept` takes. */ +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +const within = (promise: Promise, milliseconds: number, otherwise: T): Promise => + Promise.race([promise, new Promise(resolve => setTimeout(() => resolve(otherwise), milliseconds))]); + +/** A world shared with a worker that joins it claiming "threadSystemsTest", once it joined. */ +const joined = async (world: CreatedWorld): Promise<{ readonly worker: Worker }> => { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + await worker.ask({ kind: "join", port: port2 }, [port2]); + await until(world.threads, threads => threads.at(-1)?.status === "joined"); + return { worker }; +}; + +/** The scenario's results, inline and with the joined worker running the systems on its thread. */ +export const sameResults = async () => { + const inline = createWorld({ [workers]: 0 }); + const expected = await scenario(inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + const actual = await scenario(world, async entities => { + const reply = await worker.ask({ kind: "objects", entities }); + return reply.kind === "objects" ? reply.objects : []; + }); + await worker.terminate(); + await world.dispose(); + return { expected, actual }; +}; + +/** A run whose system on the worker never finishes, while the worker is ended from outside: its report. */ +export const diesMidTask = async () => { + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + const running = world.run(stall); + await new Promise(resolve => setTimeout(resolve, 50)); + await worker.terminate(); + const ran = await within(running, 20000, null); + const report = ran === null ? null : await reportOf(world, ran); + // Placed here again once its claims are released: a run on the world's thread. + const after = report === null ? null : await within(runReported(world, count), 20000, null); + await world.dispose(); + return { + report: report === null ? null : { failures: report.failures.map(failure => [failure.kind, failure.name, failure.message]), skipped: report.skipped }, + after: after === null ? null : after.failures.length, + }; +}; + +/** A worker claiming the thread of a system it never imported: why its run rejects. */ +export const unimported = async () => { + const world = createWorld({ [workers]: 0 }); + await world.run(onlyHere); + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = start(); + const reply = await worker.ask({ kind: "refused", port: port2 }, [port2]); + await worker.terminate(); + await world.dispose(); + return reply.kind === "refused" ? reply.message : null; +}; + +/** Ticks of a world-only system, back to back, while the joined worker claiming a thread never yields: how many ran. */ +export const neverYields = async (ticks: number) => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const { worker } = await joined(world); + await worker.ask({ kind: "spin" }); + world.add(count); + const counted = await within(until(Observe.resource(world, changes), value => value >= ticks), 20000, -1); + await worker.terminate(); + await world.dispose(); + driver.close(); + return counted; +}; + +/** + * The first value of a number an observer gives that reaches `target`, or -1 once it gave none for `milliseconds`: it + * stopped, rather than went on slowly. + */ +const reachedOrStopped = (observe: Observe, target: number, milliseconds: number): Promise => new Promise(resolve => { + let done = false; + let changed = performance.now(); + const finish = (value: number): void => { + if (done) return; + done = true; + clearInterval(watch); + queueMicrotask(() => stop()); + resolve(value); + }; + const watch = setInterval(() => { if (performance.now() - changed > milliseconds) finish(-1); }, milliseconds / 10); + const stop = observe(value => { + changed = performance.now(); + if (value >= target) finish(value); + }); +}); + +/** + * Ticks of a system on the joined worker, back to back, each a task that follows the last closely: how many ran, or -1 + * once they stopped for 5 s, as when a wake notified no one. + */ +export const ticksThere = async (ticks: number) => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const { worker } = await joined(world); + world.add(countThere); + const counted = await reachedOrStopped(Observe.resource(world, changes), ticks, 5000); + await worker.terminate(); + await world.dispose(); + driver.close(); + return counted; +}; + +/** + * Ticks of a system on the joined worker, back to back, while timers it queued one after another wait for it to return + * to its event loop: the latest any ran, in milliseconds, and the most runs there once one was due before it ran; null + * when they didn't all run within 20 s, as when it never returns. + */ +export const timerWhileTicking = async () => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const { worker } = await joined(world); + await worker.ask({ kind: "timers" }); + const fired = worker.next(); + world.add(counted); + const reply = await within(fired, 20000, null); + await worker.terminate(); + await world.dispose(); + driver.close(); + return { late: reply?.kind === "timers fired" ? reply.late : null, runs: reply?.kind === "timers fired" ? reply.runs : null }; +}; diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts new file mode 100644 index 000000000..981597eac --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -0,0 +1,325 @@ +import { Component } from "../../interface/component/component.js"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Observe } from "../../observe/index.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { runReported, type Reported } from "./reports.test-helper.js"; + +// Keys and systems both threads of the thread systems fixtures import: systems on thread "threadSystemsTest", which a +// joined thread fills, beside systems on the world's thread, and what each world holds after the same runs. + +export const uid = Symbol("threadSystemsTest/uid"); +export const position = Symbol("threadSystemsTest/position"); +export const velocity = Symbol("threadSystemsTest/velocity"); +export const flagged = Symbol("threadSystemsTest/flagged"); +export const caption = Symbol("threadSystemsTest/caption"); +export const note = Symbol("threadSystemsTest/note"); +export const kept = Symbol("threadSystemsTest/kept"); +export const total = Symbol("threadSystemsTest/total"); +export const changes = Symbol("threadSystemsTest/changes"); +export const paint = Symbol("threadSystemsTest/paint"); +export const painted = Symbol("threadSystemsTest/painted"); +export const enabled = Symbol("threadSystemsTest/enabled"); +export const nextUid = Symbol("threadSystemsTest/nextUid"); +declare module "@adobe/ecs" { + export interface Threads { + threadSystemsTest: true; + } + export interface Components { + [uid]: { type: number }; + [position]: { type: readonly [number, number] }; + [velocity]: { type: number }; + [flagged]: { type: true }; + [caption]: { type: { readonly text: string } | null; thread: "threadSystemsTest" }; + [note]: { type: Note | null; thread: "threadSystemsTest" }; + [kept]: { type: string }; + } + export interface Resources { + [total]: { type: number; reduce: "+" }; + [changes]: { type: number }; + [paint]: { type: { readonly text: string }; thread: "threadSystemsTest" }; + [painted]: { type: number }; + [enabled]: { type: boolean }; + [nextUid]: { type: number }; + } +} +/** A value that isn't data, which only its thread holds. */ +type Note = { readonly text: string; readonly read: () => string }; +Component.register(uid, { type: { type: "integer" } }); +Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(velocity, { type: { type: "number" } }); +Component.register(flagged, { type: { const: true } }); +const text = { type: "object", properties: { text: { type: "string" } } } as const; +Component.register(caption, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "threadSystemsTest" }); +Component.register(note, { thread: "threadSystemsTest" }); +/** An object of the world's thread. */ +Component.register(kept, { type: { type: "string" } }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(changes, { type: { type: "integer", default: 0 } }); +/** An object resource of the thread, which only systems there read and write. */ +Resource.register(paint, { type: { ...text, default: { text: "plain" } }, thread: "threadSystemsTest" }); +Resource.register(painted, { type: { type: "integer", default: 0 } }); +Resource.register(enabled, { type: { type: "boolean", default: true } }); +Resource.register(nextUid, { type: { type: "integer", default: 1 } }); + +const makeNote = (text: string): Note => ({ text, read: () => text }); + +/** Spawns from the world's thread: structs and its own object, the thread's objects at their starts. */ +export const seed = System.create({ name: "threadSystemsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } }, resources: { write: [nextUid] } }, ({ input, resources, commands }) => { + for (let k = 0; k < input.count; k++) { + const id = resources[nextUid]++; + commands.spawn([uid, position, velocity, caption, kept])({ [uid]: id, [position]: [id, -id], [velocity]: id % 3, [kept]: `k${id}` }); + } +}); + +/** On the world's thread: positions move by their velocity. */ +export const move = System.create({ name: "threadSystemsTest/move", components: { read: [velocity], write: [position] } }, row => { + const [x, y] = row[position]; + row[position] = [x + row[velocity], y - row[velocity]]; +}); + +/** On the thread: captions written in place, from the structs it reads. */ +export const describe = System.create({ name: "threadSystemsTest/describe", components: { read: [position, velocity], write: [caption] }, after: [move] }, row => { + if (row[velocity] !== 0) row[caption] = { text: `at ${row[position][0]}` }; +}); + +/** On the thread: commands giving its objects values it stages, struct values and tags, and despawns. */ +export const relabel = System.create({ name: "threadSystemsTest/relabel", components: { read: [uid, caption, position], has: [velocity] }, after: [describe] }, (row, { commands }) => { + const id = row[uid]; + if (row[position][0] > 40) { + commands.despawn(row.entity); + return; + } + if (id % 4 === 1) commands.add(row.entity, note, makeNote(`${row[caption]?.text ?? null}!`)); + if (id % 5 === 2) commands.add(row.entity, [flagged, caption], { [caption]: null }); + if (id % 7 === 3) commands.remove(row.entity, [velocity, note]); + if (id % 6 === 0) commands.add(row.entity, velocity, 2); +}); + +/** On the thread, reading its object resource: spawns giving its objects values, with a struct resource's next uid. */ +export const breed = System.create({ name: "threadSystemsTest/breed", resources: { read: [paint], write: [nextUid] }, after: [relabel] }, ({ resources, commands }) => { + const id = resources[nextUid]++; + commands.spawn([uid, position, velocity, caption, note])({ [uid]: id, [position]: [0, id], [velocity]: 1, [caption]: { text: `${resources[paint].text} ${id}` }, [note]: makeNote(`bred ${id}`) }); +}); + +/** On the thread: a reduction over its objects and structs. */ +export const tally = System.create({ name: "threadSystemsTest/tally", components: { read: [caption, position] }, reduce: { [total]: "+" }, after: [breed] }, (row, { reduce }) => { + reduce[total]((row[caption]?.text.length ?? 0) + row[position][1]); +}); + +/** On the thread: a change filter on its own objects, writing a struct resource. */ +export const watchCaptions = System.create({ name: "threadSystemsTest/watchCaptions", components: { changed: [caption] }, resources: { write: [changes] }, after: [tally] }, (_row, { resources }) => { + resources[changes] += 1; +}); + +/** On the thread, while `enabled` holds: writes its object resource, and a struct resource from it. */ +export const repaint = System.create({ name: "threadSystemsTest/repaint", resources: { write: [paint, painted], is: [enabled] }, after: [watchCaptions] }, ({ resources }) => { + resources[paint] = { text: `${resources[paint].text}+` }; + resources[painted] = resources[paint].text.length; +}); + +/** On the thread: a lookup reading an entity's structs and this thread's objects. */ +export const peek = System.create({ name: "threadSystemsTest/peek", components: { has: [uid, note] }, lookup: { self: { read: [position, caption] } }, after: [repaint] }, (row, { lookup, commands }) => { + const self = lookup.self(row.entity); + if (self !== null && ((self[caption]?.text.length ?? 0) + self[position][1]) % 2 === 1) commands.add(row.entity, flagged); +}); + +/** On the world's thread, beside `tally`: a world object from the structs. */ +export const keep = System.create({ name: "threadSystemsTest/keep", components: { read: [uid], write: [kept], added: [kept] }, after: [breed], before: [watchCaptions] }, row => { + row[kept] = `${row[kept]}.${row[uid] % 10}`; +}); + +/** On the thread, failing after its commands: later tiers are skipped, and its commands still apply. */ +export const fail = System.create({ name: "threadSystemsTest/fail", components: { read: [uid, caption] } }, (row, { commands }) => { + commands.add(row.entity, flagged); + if (row[uid] === 3 && row[caption] !== undefined) throw new Error("threadSystemsTest failure"); +}); +export const afterFail = System.create({ name: "threadSystemsTest/afterFail", components: { read: [caption] }, after: [fail] }, (row, { commands }) => { + if (row[caption] !== undefined) commands.despawn(row.entity); +}); +export const failing = System.create({ name: "threadSystemsTest/failing", systems: [fail, afterFail] }); + +/** A tick's systems, on both threads, in tiers some of which run on the joined thread alone, one after another. */ +export const tick = System.create({ name: "threadSystemsTest/tick", systems: [move, describe, relabel, breed, tally, keep, watchCaptions, repaint, peek] }); + +/** On the thread, with input, which comes to it by message. */ +export const stamp = System.create({ name: "threadSystemsTest/stamp", input: { type: "object", properties: { text: { type: "string" } } }, components: { read: [uid], write: [caption] } }, (row, { input }) => { + if (row[uid] === 1) row[caption] = { text: input.text }; +}); + +/** Disables `repaint`, from the world's thread. */ +export const disable = System.create({ name: "threadSystemsTest/disable", resources: { write: [enabled] } }, ({ resources }) => { + resources[enabled] = false; +}); + +/** What a world holds, by uid, with its resources: what both worlds compare. */ +export type Snapshot = { + readonly entities: readonly (readonly [number, { readonly [name: string]: unknown }])[]; + readonly total: number; + readonly changes: number; + readonly painted: number; +}; + +const names: readonly (readonly [symbol, string])[] = [ + [uid, "uid"], [position, "position"], [velocity, "velocity"], [flagged, "flagged"], [caption, "caption"], [note, "note"], [kept, "kept"], +]; + +/** An entity's value as data, by key names, a note by its text. */ +const plain = (value: { readonly [key: symbol]: unknown } | null): { readonly [name: string]: unknown } => { + const result: { [name: string]: unknown } = {}; + if (value === null) return result; + for (const [key, name] of names) { + if (!Object.hasOwn(value, key)) continue; + const held = value[key]; + result[name] = key === note && typeof held === "object" && held !== null ? Reflect.get(held, "text") : held; + } + return result; +}; + +/** The thread's objects of `entities`, as data, read where they live: the world itself inline, or the thread that fills them. */ +export type Objects = (entities: readonly Entity[]) => Promise; + +/** The entities `readObjects` reads, and what it read of each. */ +const reading: { entities: readonly Entity[]; objects: readonly { readonly [name: string]: unknown }[] } = { entities: [], objects: [] }; + +/** Each entity's objects of the thread, as data, a note by its text: none for an entity that's dead or has no uid. */ +const readObjects = System.create({ + name: "threadSystemsTest/readObjects", + observer: true, + lookup: { ids: { has: [uid] }, captions: { read: [caption] }, notes: { read: [note] } }, +}, ({ lookup }) => { + reading.objects = reading.entities.map(entity => { + const objects: { [name: string]: unknown } = {}; + if (lookup.ids(entity) === null) return objects; + const captioned = lookup.captions(entity); + if (captioned !== null) objects.caption = captioned[caption]; + const noted = lookup.notes(entity); + if (noted !== null) objects.note = noted[note]?.text ?? null; + return objects; + }); +}); + +/** Objects read where they live by an observer run on `threads`, as of the world's last sync point: a joined thread's, or an inline world's own. */ +export const objectsThere = (threads: World): Objects => async entities => { + reading.entities = entities; + await threads.run(readObjects); + return reading.objects; +}; + +/** What a world holds after its runs: each entity by uid, its structs and the world's objects, and the thread's objects. */ +export const snapshot = async (world: World, objects: Objects): Promise => { + const entities = await Observe.toPromise(Observe.select(world, { required: [uid] })); + const here = await Promise.all(entities.map(async entity => plain(await Observe.toPromise(Observe.entity(world, entity, { optional: [uid, position, velocity, flagged, kept] }))))); + const there = await objects(entities); + const listed = here.map((value, k) => [Number(value.uid), { ...value, ...there[k] }] as const); + return { + entities: listed.sort((a, b) => a[0] - b[0]), + total: await Observe.toPromise(Observe.resource(world, total)), + changes: await Observe.toPromise(Observe.resource(world, changes)), + painted: await Observe.toPromise(Observe.resource(world, painted)), + }; +}; + +/** A report as data both worlds compare: its warnings apart, which only a world with a joined thread has. */ +const comparable = ({ failures, skipped, dropped, warnings }: Reported) => ({ + report: { failures: failures.map(failure => [failure.kind, failure.name, failure.message]), skipped, dropped }, + warnings: warnings.map(warning => warning.name), +}); + +/** + * The runs both worlds take: seeds, ticks, a run with input, a failing run, and ticks with `repaint` disabled. Without + * `objects`, the world is inline. + */ +export const scenario = async (world: World, objects: Objects = objectsThere(world)) => { + const snapshots: Snapshot[] = []; + const reports: unknown[] = []; + const warnings: (readonly string[])[] = []; + const ran = (report: Reported): void => { + const { report: data, warnings: names } = comparable(report); + reports.push(data); + warnings.push(names); + }; + ran(await runReported(world, seed, { count: 12 })); + for (let k = 0; k < 6; k++) { + ran(await runReported(world, tick)); + snapshots.push(await snapshot(world, objects)); + } + ran(await runReported(world, stamp, { text: "stamped" })); + ran(await runReported(world, failing)); + snapshots.push(await snapshot(world, objects)); + ran(await runReported(world, disable)); + for (let k = 0; k < 2; k++) ran(await runReported(world, tick)); + snapshots.push(await snapshot(world, objects)); + return { snapshots, reports, warnings }; +}; + +/** Spawns entities with captions, from the world's thread. */ +export const crowd = System.create({ name: "threadSystemsTest/crowd", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let k = 0; k < input.count; k++) commands.spawn([uid, caption])({ [uid]: 1000 + k }); +}); +/** On the world's thread: every crowd entity but uid 1000 moves archetype, flagged on one run and not on the next. */ +export const flagCrowd = System.create({ name: "threadSystemsTest/flagCrowd", components: { read: [uid], has: [caption], not: [flagged] } }, (row, { commands }) => { + if (row[uid] !== 1000) commands.add(row.entity, flagged); +}); +export const unflagCrowd = System.create({ name: "threadSystemsTest/unflagCrowd", components: { has: [caption, flagged] } }, (row, { commands }) => { + commands.remove(row.entity, flagged); +}); +/** On the thread: writes uid 1000's caption in place. */ +export const write = System.create({ name: "threadSystemsTest/write", components: { read: [uid], write: [caption] } }, row => { + if (row[uid] === 1000) row[caption] = { text: "written" }; +}); +/** On the thread: counts captions it holds that the thread wrote, into a struct resource. */ +export const countWritten = System.create({ name: "threadSystemsTest/countWritten", components: { read: [caption] }, resources: { write: [changes] } }, (row, { resources }) => { + if (row[caption]?.text === "written") resources[changes] += 1; +}); + +/** On the thread: gives an object of the world's thread a value, which can't cross, so its command drops and it fails. */ +export const giveKept = System.create({ name: "threadSystemsTest/giveKept", components: { read: [caption] } }, (row, { commands }) => { + if (row[caption] !== undefined) commands.add(row.entity, kept, "given there"); +}); +/** On the thread: spawns giving an object of the world's thread a value, which throws when it's issued. */ +export const spawnKept = System.create({ name: "threadSystemsTest/spawnKept", resources: { read: [paint] } }, ({ resources, commands }) => { + commands.spawn([uid, kept])({ [uid]: resources[paint].text.length, [kept]: "spawned there" }); +}); + +/** On the thread: runs until its thread ends, so its task never completes. */ +export const stall = System.create({ name: "threadSystemsTest/stall", resources: { read: [paint] } }, ({ resources }) => { + while (resources[paint].text !== "never") { /* until the thread ends */ } +}); +/** On the world's thread: counts its runs, ticking with nothing on the thread. */ +export const count = System.create({ name: "threadSystemsTest/count", resources: { write: [changes] } }, ({ resources }) => { + resources[changes] += 1; +}); +/** + * Runs of `counted` on this thread, and how many had run once `due`, a time in `performance.now()`, passed; and a + * timer's callback for its next run to set, while it runs a task, as a thread's systems do. + */ +export const ranHere: { runs: number; due: number; atDue: number; queued: (() => void) | null } = { runs: 0, due: Infinity, atDue: -1, queued: null }; + +/** + * Sets the queued timer, which runs only once this thread returns to its event loop, and when it's due: in node 2 ms + * after a timer of 1 ms, as the loop's clock steps in milliseconds; in a browser at once, for a timer of 0 ms. + */ +const setQueued = (task: () => void): void => { + const node = globalThis.process?.versions?.node !== undefined; + setTimeout(task, node ? 1 : 0); + ranHere.due = performance.now() + (node ? 2 : 0); + ranHere.atDue = -1; +}; +/** On the thread: counts its runs, so the world's ticks post it a task each. */ +export const countThere = System.create({ name: "threadSystemsTest/countThere", resources: { read: [paint], write: [changes] } }, ({ resources }) => { + if (resources[paint].text !== "never") resources[changes] += 1; +}); + +/** On the thread: counts its runs there in `ranHere`; it reaches its commands, so it blocks its tier, a task a tick. */ +export const counted = System.create({ name: "threadSystemsTest/counted", resources: { read: [paint] } }, ({ resources, commands }) => { + void resources[paint]; + void commands; + ranHere.runs++; + if (ranHere.queued !== null) { + setQueued(ranHere.queued); + ranHere.queued = null; + } + if (ranHere.atDue < 0 && performance.now() >= ranHere.due) ranHere.atDue = ranHere.runs; +}); diff --git a/packages/ecs/src/implementation/world/thread-systems.test.ts b/packages/ecs/src/implementation/world/thread-systems.test.ts new file mode 100644 index 000000000..ca219fae6 --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-systems.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { workers } from "../../interface/resources/workers.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { pageBytes } from "./memory.js"; +import { Observe } from "../../observe/index.js"; +import { caption, changes, countWritten, crowd, flagCrowd, giveKept, kept, objectsThere, scenario, spawnKept, uid, unflagCrowd, write } from "./thread-systems.test-helper.js"; +import { reportOf, runReported } from "./reports.test-helper.js"; + +// Systems on a joined thread, with the world and the thread on this one, joined through a MessageChannel. + +/** The first value an observer gives that `accept` takes. */ +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +/** Once the thread shared last has status `status`. */ +const statusOf = (world: CreatedWorld, status: "joined" | "gone") => + until(world.threads, threads => threads.at(-1)?.status === status); + +/** A world shared with a thread on this one, which claims the systems' thread. */ +const joined = async (): Promise<{ readonly world: CreatedWorld; readonly remote: World }> => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["threadSystemsTest"] }); + await statusOf(world, "joined"); + return { world, remote }; +}; + +describe("systems on a joined thread", () => { + it("give an inline world's results: rows, commands, spawns, reductions, resources, lookups, filters, conditions and failures", async () => { + const inline = createWorld({ [workers]: 0 }); + const expected = await scenario(inline); + await inline.dispose(); + const { world, remote } = await joined(); + const actual = await scenario(world, objectsThere(remote)); + expect(actual.snapshots).toEqual(expected.snapshots); + expect(actual.reports).toEqual(expected.reports); + expect(expected.warnings.every(names => names.length === 0)).toBe(true); + // Every tick waits for the joined thread's tiers. + expect(actual.warnings[1]).toEqual(["threadSystemsTest/describe", "threadSystemsTest/relabel", "threadSystemsTest/breed", "threadSystemsTest/tally", "threadSystemsTest/watchCaptions", "threadSystemsTest/repaint", "threadSystemsTest/peek"]); + await remote.dispose(); + await world.dispose(); + }); + it("keep a value the thread's systems wrote, once its log lapped and it rebuilt", async () => { + const { world, remote } = await joined(); + await world.run(crowd, { count: 200 }); + await world.run(write); + // Moves past what the log holds, while the thread runs nothing and so replays nothing. + for (let k = 0; k < 30; k++) await world.run(k % 2 === 0 ? flagCrowd : unflagCrowd); + await world.run(countWritten); + expect(await Observe.toPromise(Observe.resource(world, changes))).toBe(1); + await remote.dispose(); + await world.dispose(); + }); + it("give the world's strings only their start: a command giving another drops and fails its system, a spawn throws", async () => { + const { world, remote } = await joined(); + await world.run(crowd, { count: 1 }); + const given = await runReported(world, giveKept); + expect(given.failures.map(failure => [failure.name, failure.message])).toEqual([["threadSystemsTest/giveKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); + expect(given.dropped).toBe(1); + const spawned = await runReported(world, spawnKept); + expect(spawned.failures.map(failure => [failure.name, failure.message])).toEqual([["threadSystemsTest/spawnKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); + expect(await Observe.toPromise(Observe.count(world, { required: [kept] }))).toBe(0); + await remote.dispose(); + await world.dispose(); + }); + it("are skipped with a \"thread\" failure once their thread is gone, and run here once its claims are released", async () => { + const { world, remote } = await joined(); + await world.run(crowd, { count: 1 }); + await world.run(write); + const leaving = remote.dispose(); + const report = await runReported(world, write); + await leaving; + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["thread", "threadSystemsTest/write", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']]); + expect(report.skipped).toEqual(["threadSystemsTest/write"]); + const [entity] = await Observe.toPromise(Observe.select(world, { required: [uid] })); + expect(await runReported(world, write)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); + expect(await Observe.toPromise(Observe.entity(world, entity, { required: [caption] }))).toEqual({ [caption]: { text: "written" } }); + await world.dispose(); + }); + it("fail every system of a task the thread can't view the memory for, its first and a later one, rather than report they ran", async () => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + // The memory the thread is given, whose header's first word is the length in pages the world stored. + const given = new Promise(resolve => { + channel.port2.addEventListener("message", (event: MessageEvent) => { + const memory: unknown = typeof event.data === "object" && event.data !== null ? Reflect.get(event.data, "memory") : undefined; + if (memory instanceof WebAssembly.Memory) resolve(memory); + }); + }); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["threadSystemsTest"] }); + await statusOf(world, "joined"); + await world.run(crowd, { count: 1 }); + const header = new Uint32Array((await given).buffer, 0, 1); + const pages = Atomics.load(header, 0); + /** A run of `write` while the world says the memory is a page longer than it is: its number, or null once it never settles. */ + const short = async () => { + Atomics.store(header, 0, pages + 1); + const ran = await Promise.race([world.run(write), new Promise(resolve => setTimeout(() => resolve(null), 5000))]); + Atomics.store(header, 0, pages); + return ran; + }; + const failuresOf = async (ran: number | null) => ran === null ? null : (await reportOf(world, ran)).failures.map(failure => [failure.kind, failure.name, failure.message]); + const failed = [["system", "threadSystemsTest/write", `This thread's memory is ${pages * pageBytes} bytes, short of the ${(pages + 1) * pageBytes} the world stored.`]]; + // Read once every run ran, as the thread saw them. + const runs = [await short(), await world.run(write), await short(), await world.run(write)]; + expect(await Promise.all(runs.map(failuresOf))).toEqual([failed, [], failed, []]); + await remote.dispose(); + await world.dispose(); + }); +}); \ No newline at end of file diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts new file mode 100644 index 000000000..87102e902 --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -0,0 +1,1200 @@ +import type { Entity } from '../../interface/entity/entity.js'; +import type { Commands } from '../../interface/system/commands.js'; +import type { Leaf } from '../../interface/system/leaf.js'; +import type { SystemLookup } from '../../interface/system/access.js'; +import { declarationOf, definitionOf, firstKeyOf, indexOfLookup, isLeaf, itemOfId, kindOf, labelOf, programFinalizers, threadOfDeclaration, watchedBy } from '../../interface/registry/index.js'; +import { layoutOf, objectLayout, viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; +import type { StringStore } from './strings.js'; +import { stampChanged, writeOf } from './archetype.js'; +import { emptyViews } from './arrays.js'; +import { readCell, writeCell } from './cells.js'; +import { + beginCommand, createRingWriter, dropCommand, encoderOf, endSegment, localNumbers, objectStart, putComponent, putNull, putSlot, putStart, putValue, + startSegment, type Encoder, type Extent, type Slots, +} from './command-ring.js'; +import { addCommand, despawnCommand, removeCommand } from './buffer.js'; +import { createBlock, endBlock, locate, reserveFrom, type Block } from './entities.js'; +import { inputFor } from './input.js'; +import { protocolName, type ToWorld } from './joining.js'; +import { ownTag } from './move-log.js'; +import { ranExecuted, ranSkipped } from './joined-observer.js'; +import { applying, rebuild, replay, stage, waitStructure, type Mirror } from './mirror.js'; +import type { WorkerError } from './protocol.js'; +import { forEachPassingRange, type Filter } from './ranges.js'; +import { combine, contributed, identityOf, reductionTargetsOf, type ReductionTarget } from './reductions.js'; +import { changedRowOf, emptyRow, rowBinderOf, type RowHost } from './rows.js'; +import type { Since } from './index-lookups.js'; +import { createBatch, positionalSpawner, type Positional, type SpawnBatch } from './staging.js'; +import { createThreadFinalizers } from './thread-finalizers.js'; +import { createThreadIndex, type ThreadIndex } from './thread-indexes.js'; +import type { EntityViews, ThreadArchetype, ThreadDirectory, ThreadStorage } from './thread-directory.js'; +import { keptSorted } from './index-keys.js'; +import { chunkShift } from './kernel.js'; +import { + doneWord, endWord, headExecuted, headRan, progressWord, snapshotFlagsWord, snapshotLayout, snapshotOffsetWord, snapshotResourceArchetypeWord, + snapshotResourceRowWord, snapshotRowsWord, snapshotCapacityWord, snapshotted, tailBlockWords, tailChunk, waitingWord, +} from './tail-blocks.js'; +import { defaultOf, type Key } from './state.js'; +import { + completionCapacityWord, completionMessage, completionOffsetWord, completionSystems, executedFlag, failedFlag, payloadCapacityWord, payloadCount, + payloadInput, payloadOffsetWord, payloadSequence, payloadSystems, ranFlag, resultEnd, resultFields, resultFlags, resultStart, ringBaseWord, ringCapacityWord, + payloadSync, ringCursorWord, ringNeededWord, spilledFlag, systemFields, systemFolds, systemIndex, systemLastRun, systemTail, systemTick, systemTier, + taskDoneWord, taskFailedWord, taskPostedWord, +} from './thread-tasks.js'; + +// A joined thread's systems (threads.md, "Systems on other threads"): runners over the world's archetypes as the layout +// directory describes them, struct columns and resources in shared memory, this thread's objects in its mirror +// (`mirror.ts`), with commands and spawns written into its command ring, run as tasks the world posts. + +/** For tests: tails on this thread that read every row from a snapshot, being late, and that switched to one at a chunk. */ +export const tailSnapshots = { late: 0, switched: 0 }; + +/** + * A system or finalizer the world places on this thread: its index here, its id, and the world's numbers for the keys + * it names; for a system, the byte offset of its removed word. + */ +/** A system the world places on this thread: its index here, id, keys' numbers, removed word, and by lookup name its index records. */ +export type SystemSetup = { readonly index: number; readonly id: string; readonly keys: readonly (readonly [number, string])[]; readonly removed?: number; readonly indexes?: readonly (readonly [string, number])[] }; + +/** What the joined thread gives its systems. */ +export type ThreadSystemsHost = { + readonly memory: WebAssembly.Memory; + readonly mirror: Mirror; + /** This thread's view of the layout directory, over the same mirror. */ + readonly directory: ThreadDirectory; + /** The task block's words (`thread-tasks.ts`). */ + readonly control: Int32Array; + /** The logical threads this thread fills. */ + readonly claims: ReadonlySet; + /** Whether the world waits without blocking, so `Atomics.notify` wakes it; else a message does. */ + readonly worldWaits: boolean; + readonly post: (message: ToWorld, transfer?: readonly ArrayBuffer[]) => void; + /** This thread's number for a key, its id posted the first time; throws for a key without one. */ + readonly keyNumber: (key: Key) => number; + /** How many of this thread's numbers were posted, so a task that posted more says a message follows. */ + readonly numbered: () => number; +}; + +const errorOf = (error: unknown): WorkerError => + error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : { name: "Error", message: String(error) }; + +const noTicks = new Float64Array(0); +const noTags = new Uint32Array(0); +const noBatches: readonly SpawnBatch[] = []; + +/** A command's key: what it is to the world, and how this thread writes its values. */ +type CommandKey = { + readonly number: number; + readonly layout: Layout; + readonly encoder: Encoder; + /** Whether its values are this thread's objects, staged here; else, for an object, why only its start or null crosses. */ + readonly own: boolean; + readonly elsewhere: string | null; + readonly start: unknown; + /** Whether it's declared `transfer`, so a staged value whose command drops is released. */ + readonly transfer: boolean; +}; + +/** The system that ran a task's commands, for their checks' errors. */ +type Running = { error: unknown }; + +/** + * Runs the systems the world places on this thread, as tasks it posts in the task block: each task's systems in order, + * after replaying the mirror through the task's sync, writing each system's commands and spawns as a segment of the + * command ring, its flags and folds into the completion, then storing the task done and waking the world. + */ +export const createThreadSystems = (host: ThreadSystemsHost) => { + const { mirror, control, directory } = host; + const { viewed, rows: rowHost, numbers, refreshEntities, archetypeAt, countOf, resourceRow, tableCount } = directory; + const refreshMemory = (): void => { directory.refresh(); }; + const runners: (ThreadRunner | null | undefined)[] = []; + const inputs = new Map(); + /** The task waiting for what it needs: its systems' setups, its input, or this thread's objects; -1 for none. */ + let pending = -1; + const writer = createRingWriter(); + let block: Block | null = null; + const unused: number[] = []; + + /** Whether a component's values are objects of a thread this one fills. */ + const ownObject = (key: Key): boolean => { + const declaration = declarationOf(key); + const thread = declaration === undefined ? null : threadOfDeclaration(declaration); + return thread !== null && host.claims.has(thread) && layoutOf(declaration?.type).kind === "object"; + }; + + const commandKeys = new Map(); + const commandKeysByNumber = new Map(); + /** By sorted numbers of a list's components, the batch its spawns stage into, and by list, its spawner. */ + const batches = new Map(); + const usedBatches: SpawnBatch[] = []; + /** Spawners by list, and by the numbers of a list's components in its order, for lists made again each call. */ + const spawners = new WeakMap Entity>(); + const spawnersByNumbers = new Map Entity>(); + + /** A key a command names: checked as the world's commands check it, numbered by the world's number or this thread's. */ + const commandKeyOf = (key: unknown): CommandKey => { + if (typeof key !== "symbol") throw new Error(Array.isArray(key) ? "A command names a list of components where one component's symbol goes." : `"${labelOf(key)}" isn't a component: a component's key is a symbol.`); + const direct = commandKeys.get(key); + if (direct !== undefined) return direct; + const first = firstKeyOf(key); + const known = commandKeys.get(first); + if (known !== undefined) { + commandKeys.set(key, known); + return known; + } + const declaration = declarationOf(key); + if (declaration === undefined || kindOf(key) !== "component") throw new Error(kindOf(key) === "resource" ? `"${labelOf(key)}" is a resource, not a component.` : `"${labelOf(key)}" has no registered schema; import the module that registers it.`); + if (declaration.derived === true) throw new Error(`"${labelOf(key)}" is derived, so only rules add and remove it.`); + const layout = layoutOf(declaration.type); + const own = ownObject(key); + const thread = threadOfDeclaration(declaration); + const number = numbers.get(first) ?? localNumbers + host.keyNumber(key); + const entry: CommandKey = { + number, layout, encoder: encoderOf(layout), own, + elsewhere: layout.kind === "string" ? `"${labelOf(key)}" is a string, which only the world's thread writes, so a command on another thread gives it only its start.` + : layout.kind === "object" && !own ? `"${labelOf(key)}" lives on thread "${thread ?? "world"}", so a command on another thread gives it only its start or null.` : null, + start: layout.kind === "constant" ? layout.value : defaultOf(key), + transfer: declaration.transfer === true, + }; + commandKeys.set(first, entry); + commandKeys.set(key, entry); + commandKeysByNumber.set(number, entry); + return entry; + }; + + /** The system whose commands are being written, so a value that can't cross fails it. */ + const running: Running = { error: undefined }; + /** The tick of the system running, which staged values settle by. */ + let sync = 0; + + /** Writes one value of an add: a struct's bytes; an object of this thread staged; another's start or null, else dropped. */ + const putOne = (key: CommandKey, entity: number, value: unknown): boolean => { + const { layout, number } = key; + if (layout.kind !== "object" && layout.kind !== "string") { + if (value === undefined) putStart(writer, number); + else putValue(writer, number, key.encoder, value); + return true; + } + if (value === undefined || value === key.start) putStart(writer, number); + else if (value === null) putNull(writer, number); + else if (key.own) putSlot(writer, number, stage(mirror, entity, number, value, sync, key.transfer)); + else { + running.error ??= new Error(key.elsewhere ?? ""); + return false; + } + return true; + }; + + /** Spawns this thread stages of a list's components, into a batch per set, reserving entities in blocks. */ + const spawnerOf = (list: readonly unknown[]): ((values: object) => Entity) => { + const known = spawners.get(list); + if (known !== undefined) return known; + const listed = list.map(commandKeyOf); + const ids = listed.map(entry => entry.number); + const order = ids.join(","); + const same = spawnersByNumbers.get(order); + if (same !== undefined) { + spawners.set(list, same); + return same; + } + if (new Set(ids).size !== ids.length) throw new Error("A spawn names a component twice."); + const sorted = [...ids].sort((a, b) => a - b); + const signature = sorted.join(","); + let batch = batches.get(signature); + if (batch === undefined) { + const byNumber: Layout[] = []; + listed.forEach(entry => { byNumber[entry.number] = entry.layout; }); + batch = createBatch(byNumber, ids); + for (const column of batch.columns) { + const entry = listed.find(candidate => candidate.number === column.id); + if (entry?.elsewhere !== null) column.remote = entry?.elsewhere ?? null; + } + batches.set(signature, batch); + } + const target = batch; + const reserving = block ??= createBlock(refreshEntities()); + // An object's or a string's start is the world's to resolve. + const positional: Positional = positionalSpawner(reserving, reserveFrom, target, ids, listed.map(entry => entry.layout.kind === "object" || entry.layout.kind === "string" ? objectStart : entry.start)); + const keysOfList = list.filter((key): key is Key => typeof key === "symbol"); + const args: unknown[] = new Array(keysOfList.length); + const spawn = (values: object): Entity => { + for (let k = 0; k < keysOfList.length; k++) args[k] = Reflect.get(values, keysOfList[k]); + if (!usedBatches.includes(target)) usedBatches.push(target); + return Reflect.apply(positional, undefined, args); + }; + spawners.set(list, spawn); + spawnersByNumbers.set(order, spawn); + return spawn; + }; + + const empty = {}; + const commands: Commands = { + spawn: components => spawnerOf(components), + despawn: entity => beginCommand(writer, despawnCommand, entity), + add: (entity: number, components: symbol | readonly symbol[], values?: unknown) => { + if (typeof components === "symbol") { + const key = commandKeyOf(components); + beginCommand(writer, addCommand, entity); + if (!putOne(key, entity, values)) dropCommand(writer); + return; + } + // Every key is checked before anything is written. + for (let k = 0; k < components.length; k++) commandKeyOf(components[k]); + const given = typeof values === "object" && values !== null ? values : empty; + beginCommand(writer, addCommand, entity); + let crosses = true; + for (let k = 0; k < components.length; k++) if (!putOne(commandKeyOf(components[k]), entity, Reflect.get(given, components[k]))) crosses = false; + if (!crosses) dropCommand(writer); + }, + remove: (entity: number, components: symbol | readonly symbol[]) => { + if (typeof components === "symbol") { + const { number } = commandKeyOf(components); + beginCommand(writer, removeCommand, entity); + putComponent(writer, number); + return; + } + for (let k = 0; k < components.length; k++) commandKeyOf(components[k]); + beginCommand(writer, removeCommand, entity); + for (let k = 0; k < components.length; k++) putComponent(writer, commandKeyOf(components[k]).number); + }, + }; + + /** Stages a value a spawn gives this thread's object, as its segment is written. */ + const slots: Slots = (component, entity, value) => { + if (value === undefined || value === null || value === objectStart) return -1; + const key = commandKeysByNumber.get(component); + return key?.own === true ? stage(mirror, entity, component, value, sync, key.transfer) : -1; + }; + + /** Writes the running system's segment: its commands, its spawns, and the reservations it left unused. */ + const writeSegment = (): Extent => { + unused.length = 0; + if (block !== null) endBlock(block, unused); + const cursor = Atomics.load(control, ringCursorWord); + if (writer.count === 0 && usedBatches.length === 0 && unused.length === 0) return { start: cursor, end: cursor, spill: null, transfer: null }; + const extent = endSegment(writer, viewed.views, Atomics.load(control, ringBaseWord) >>> 0, Atomics.load(control, ringCapacityWord), cursor, + usedBatches.length === 0 ? noBatches : usedBatches, unused, slots); + usedBatches.length = 0; + Atomics.store(control, ringCursorWord, extent.end); + Atomics.store(control, ringNeededWord, Atomics.load(control, ringNeededWord) + extent.end - extent.start + (extent.spill?.length ?? 0)); + return extent; + }; + + const runnerHost: RunnerHost = { + rows: rowHost, archetypeAt, countOf, tableCount, resourceRow, refreshEntities, commands, mirror, + refresh: directory.refresh, + numberOf: directory.numberOf, + layoutOf: directory.layoutOf, + strings: directory.strings, + }; + + /** Takes the world's numbers for the keys of systems or finalizers it places here. */ + const name = directory.name; + + /** By index: the byte offset of the word the world sets once it removed the system there, which no task runs from then on. */ + const removedWords: (number | undefined)[] = []; + /** By index: the block of its tail's last task, whose view goes with it. */ + const tailBlocks: (number | undefined)[] = []; + /** Systems the world removed, dropped once the thread finished `sequence`, the last task posted before. */ + const unplacing: { readonly indexes: readonly number[]; readonly sequence: number }[] = []; + + /** Records the systems the world places here; one this thread never imported fails each task that names it. */ + const setup = (systems: readonly SystemSetup[]): void => { + name(systems); + for (const { index, id, removed, indexes } of systems) { + const item = itemOfId(id); + const definition = item === undefined ? undefined : definitionOf(item); + runners[index] = definition === undefined || !isLeaf(definition) ? null : createThreadRunner(runnerHost, definition, new Map(indexes)); + removedWords[index] = removed; + } + }; + + /** Drops what's held for removed systems no task runs again, and tells the world, so it may place their indexes again. */ + const dropUnplaced = (): void => { + if (unplacing.length === 0) return; + const done = Atomics.load(control, taskDoneWord); + const dropped: number[] = []; + for (let k = unplacing.length - 1; k >= 0; k--) { + const { indexes, sequence } = unplacing[k]; + if (sequence > done) continue; + unplacing.splice(k, 1); + for (const index of indexes) { + runners[index] = undefined; + removedWords[index] = undefined; + const block = tailBlocks[index]; + if (block !== undefined) tailControls.delete(block); + tailBlocks[index] = undefined; + dropped.push(index); + } + } + if (dropped.length > 0) host.post({ protocol: protocolName, kind: "unplaced", indexes: dropped }); + }; + + /** The world removed systems here: dropped once the last task posted before them is done. */ + const unplace = (indexes: readonly number[], sequence: number): void => { + unplacing.push({ indexes, sequence }); + dropUnplaced(); + }; + + const finalizers = createThreadFinalizers({ + mirror, + refresh: directory.refresh, + rows: rowHost, + numberOf: runnerHost.numberOf, + layoutOf: runnerHost.layoutOf, + archetypeAt, countOf, tableCount, resourceRow, + strings: directory.strings, + failed: errors => host.post({ protocol: protocolName, kind: "finalized", errors }), + }); + mirror.finalize = finalizers.through; + + /** Records the finalizers the world places here; one this thread never imported fails each group of its stops. */ + const setupFinalizers = (setups: readonly SystemSetup[]): void => { + name(setups); + finalizers.setup(setups.map(({ index, id }) => { + const item = itemOfId(id); + const leaf = programFinalizers().find(finalizer => finalizer === item); + return { index, leaf: leaf?.kind === "finalizer" ? leaf : null }; + })); + }; + + /** Brings the mirror up to the last published sync, which runs the finalizers of its stops; false while it can't. */ + const current = (): boolean => { + const replayed = replay(mirror); + if (replayed === "held") return false; + return replayed !== "lapped" || rebuild(mirror); + }; + + /** Whether a task can run: its systems known here, its input arrived, and the mirror through its sync. */ + const ready = (payload: Float64Array, count: number): boolean => { + for (let k = 0; k < count; k++) if (runners[payload[payloadSystems + k * systemFields + systemIndex]] === undefined) return false; + if (payload[payloadInput] !== 0 && !inputs.has(payload[payloadSequence])) return false; + return current(); + }; + + /** Views of the payload and completion, kept while the world keeps them where they are. */ + let payload: Float64Array = new Float64Array(0); + let completion: Float64Array = new Float64Array(0); + const viewTask = (): void => { + const payloadOffset = Atomics.load(control, payloadOffsetWord) >>> 0; + const completionOffset = Atomics.load(control, completionOffsetWord) >>> 0; + const { buffer } = viewed; + if (payload.buffer !== buffer || payload.byteOffset !== payloadOffset || payload.length !== Atomics.load(control, payloadCapacityWord) / 8) { + payload = new Float64Array(buffer, payloadOffset, Atomics.load(control, payloadCapacityWord) / 8); + } + if (completion.buffer !== buffer || completion.byteOffset !== completionOffset || completion.length !== Atomics.load(control, completionCapacityWord) / 8) { + completion = new Float64Array(buffer, completionOffset, Atomics.load(control, completionCapacityWord) / 8); + } + }; + + /** + * Fails every system of the posted task, when this thread can't run it: its memory is short of the world's, or its + * views of it failed. It writes nothing the views would reach, as they may be stale: the failed word says the task + * failed whole, and the message, of the sequence the world posted, why. + */ + const failTask = (posted: number, error: unknown): void => { + pending = -1; + host.post({ protocol: protocolName, kind: "completed", sequence: posted, errors: [[0, errorOf(error)]], spills: [] }); + Atomics.store(control, taskFailedWord, posted); + Atomics.store(control, taskDoneWord, posted); + Atomics.notify(control, taskDoneWord); + }; + + /** + * Runs the posted task, if any and it can run; else it waits for what it needs, and `process` runs it then. Returns + * whether it finished one. + */ + const process = (): boolean => { + const posted = Atomics.load(control, taskPostedWord); + if (posted === Atomics.load(control, taskDoneWord)) return false; + try { + runTask(posted); + } + catch (error) { + failTask(posted, error); + } + dropUnplaced(); + return Atomics.load(control, taskDoneWord) === posted; + }; + + /** The objects tails of the task wrote from their snapshots, for the world to stamp. */ + const stamps: number[] = []; + /** Views of tails' blocks by offset, kept while the memory's buffer is the same. */ + const tailControls = new Map(); + const tailControl = (offset: number): Int32Array => { + let control = tailControls.get(offset); + if (control === undefined || control.buffer !== viewed.buffer) tailControls.set(offset, control = new Int32Array(viewed.buffer, offset, tailBlockWords)); + return control; + }; + + /** A task waiting for a sync point to finish applying, to rebuild its mirror, runs once it has. */ + let structureWaited = false; + const retryAfterStructure = (): void => { + if (structureWaited || !applying(mirror)) return; + structureWaited = true; + void waitStructure(mirror).then(() => { + structureWaited = false; + process(); + }); + }; + + const runTask = (posted: number): void => { + refreshMemory(); + viewTask(); + const count = payload[payloadCount]; + const sequence = payload[payloadSequence]; + pending = sequence; + if (!ready(payload, count)) { + retryAfterStructure(); + return; + } + pending = -1; + refreshEntities(); + const input = inputs.get(sequence); + inputs.delete(sequence); + const numberedBefore = host.numbered(); + let errors: [number, WorkerError][] | null = null; + let spills: [number, Uint8Array][] | null = null; + let transfer: ArrayBuffer[] | null = null; + let failedTier = -1; + const folds = completionSystems + count * resultFields; + stamps.length = 0; + for (let k = 0; k < count; k++) { + const base = payloadSystems + k * systemFields; + const result = completionSystems + k * resultFields; + const tier = payload[base + systemTier]; + completion[result + resultFlags] = 0; + completion[result + resultStart] = completion[result + resultEnd] = 0; + // A failed tier stops the task after it: later tiers are skipped. + if (failedTier >= 0 && tier > failedTier) continue; + const index = payload[base + systemIndex]; + // Removed since the task was posted: it runs no more, and gives back nothing. + const removed = removedWords[index]; + if (removed !== undefined && Atomics.load(viewed.words, removed >>> 2) !== 0) continue; + const runner = runners[index]; + const tick = payload[base + systemTick]; + rowHost.tick = sync = tick; + running.error = undefined; + startSegment(writer, { tick, archetype: 0, start: k }); + let flags = 0; + let error: unknown = undefined; + const tail = payload[base + systemTail]; + if (tail !== 0 && runner !== null && runner !== undefined) { + tailBlocks[index] = tail; + // A tail writes no commands, so no segment. + const outcome = runner.runTail(payload[base + systemLastRun], input, tailControl(tail), payload[payloadSync], k, stamps); + if (outcome.executed) flags |= executedFlag; + if (outcome.ran) flags |= ranFlag; + if (outcome.error !== undefined) { + flags |= failedFlag; + (errors ??= []).push([k, errorOf(outcome.error)]); + } + completion[result + resultFlags] = flags; + continue; + } + if (runner === null || runner === undefined) error = new Error("A system the world placed on this thread isn't one this thread imported."); + else { + const outcome = runner.run(payload[base + systemLastRun], input); + if (outcome.executed) flags |= executedFlag; + if (outcome.ran) flags |= ranFlag; + error = outcome.error ?? running.error; + } + try { + const extent = writeSegment(); + completion[result + resultStart] = extent.start; + completion[result + resultEnd] = extent.end; + if (extent.spill !== null && extent.transfer !== null) { + flags |= spilledFlag; + (spills ??= []).push([k, extent.spill]); + (transfer ??= []).push(extent.transfer); + } + } + catch (thrown) { + error ??= thrown; + } + if (error !== undefined) { + flags |= failedFlag; + (errors ??= []).push([k, errorOf(error)]); + failedTier = tier; + } + else if (runner !== null && runner !== undefined && (flags & ranFlag) !== 0) runner.writeFolds(completion, folds + payload[base + systemFolds]); + completion[result + resultFlags] = flags; + } + const message = errors !== null || spills !== null || stamps.length > 0 || host.numbered() !== numberedBefore || !host.worldWaits; + completion[completionMessage] = message ? 1 : 0; + if (message) host.post({ protocol: protocolName, kind: "completed", sequence, errors: errors ?? [], spills: spills ?? [], ...stamps.length > 0 ? { stamps: [...stamps] } : {} }, transfer ?? []); + Atomics.store(control, taskDoneWord, posted); + Atomics.notify(control, taskDoneWord); + }; + + return { + setup, + setupFinalizers, + /** + * Runs the finalizers of stops published since this thread last replayed, if any, as it replays them; and + * releases staged `transfer` values whose commands dropped. + */ + finalize: (): void => { + if (finalizers.pending() || mirror.stagedTransfers > 0) current(); + }, + /** Once the world is disposed: finalizers run for the stops published, then for every row they match. */ + disposed: (): void => { + try { + if (current()) finalizers.all(); + } + catch (error) { + host.post({ protocol: protocolName, kind: "finalized", errors: [[-1, errorOf(error)]] }); + } + }, + /** A task's input, posted by message. */ + input: (sequence: number, input: unknown): void => { + inputs.set(sequence, input); + if (pending === sequence) process(); + }, + process, + /** Whether a task waits for what it needs. */ + waiting: (): boolean => pending >= 0, + unplace, + /** For tests of what a thread holds: how many systems the world placed here that it keeps. */ + systems: (): number => runners.filter(runner => runner !== undefined).length, + }; +}; + +/** What a joined thread's runner needs of its systems' host. */ +export type RunnerHost = { + readonly rows: RowHost & { tick: number }; + readonly archetypeAt: (id: number) => ThreadArchetype | undefined; + readonly countOf: (archetype: ThreadArchetype) => number; + readonly tableCount: () => number; + readonly resourceRow: () => { readonly archetype: ThreadArchetype; readonly row: number } | null; + readonly refreshEntities: () => EntityViews; + readonly commands: Commands; + readonly numberOf: (key: Key) => number; + readonly layoutOf: (key: Key) => Layout | undefined; + /** This thread's objects, null where it fills none. */ + readonly mirror: Mirror | null; + /** Views of the memory again, once it grew: its buffer. */ + readonly refresh: () => ArrayBufferLike; + /** The records strings' columns name. */ + readonly strings: StringStore; +}; + +/** One run's outcome: whether it took its tick, ran its rows, and its error. */ +export type Outcome = { executed: boolean; ran: boolean; error: unknown }; + +export type ThreadRunner = { + /** Runs it once, at the tick its host's rows stamp with. */ + readonly run: (lastRun: number, input: unknown) => Outcome; + /** + * Runs it once as a tail, through its block `control`, as of sync `sync`: claiming its rows in chunks where they + * are, and from the snapshot once the world took one. Objects it wrote from the snapshot are pushed to `stamps` + * as `position`, entity and component, for the world to stamp; -1 names the resource entity. + */ + readonly runTail: (lastRun: number, input: unknown, control: Int32Array, sync: number, position: number, stamps: number[], first?: number, outcomeAt?: number) => Outcome; + /** Stops it at once, from its next row on: its system was removed on this thread. */ + readonly halt: () => void; + /** Writes its folds' leaves from `at`. */ + readonly writeFolds: (completion: Float64Array, at: number) => void; +}; + +const noRecords: ReadonlyMap = new Map(); + +/** A resource's cell on this thread, as of a run. */ +type ThreadCell = { storage: ThreadStorage | undefined; row: number }; + +/** Runs one system here, as the world's runner runs a main-thread system (`runner.ts`). */ +export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: ReadonlyMap = noRecords): ThreadRunner => { + const { declaration } = leaf; + // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. + const system: { body(subject: unknown, context?: unknown): void } = leaf; + const { components: query, resources } = declaration; + const numberOf = host.numberOf; + const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(numberOf); + const excluded = (query?.not ?? []).map(numberOf); + const addedIds = (query?.added ?? []).map(numberOf); + const changedIds = (query?.changed ?? []).map(numberOf); + const resourceChanged = (resources?.changed ?? []).map(numberOf); + const filtered = addedIds.length + changedIds.length + resourceChanged.length > 0; + const bindRow = rowBinderOf(host.rows, numberOf, query?.read ?? [], query?.write ?? []); + const matches: number[] = []; + let checked = 0; + const refreshMatches = (): void => { + for (const count = host.tableCount(); checked < count; checked++) { + const archetype = host.archetypeAt(checked); + if (archetype === undefined) break; + const has = (id: number): boolean => archetype.components.includes(id); + if (!archetype.resource && !(archetype.meta && required.length === 0) && required.every(has) && !excluded.some(has)) matches.push(checked); + } + }; + const ownInput = inputFor(leaf); + const targets: readonly ReductionTarget[] = reductionTargetsOf(host.layoutOf, declaration); + const reduced = targets.map(target => new Float64Array(target.leaves.length)); + + // The context: resources, lookups, reductions, input and commands, each under its own key when declared. + const own = (): object => Object.create({}); + const value: { input?: unknown } = own(); + const writes = new Set(resources?.write); + const resourceKeys = [...new Set([...resources?.read ?? [], ...resources?.is ?? [], ...writes])]; + const cells: ThreadCell[] = resourceKeys.map(() => ({ storage: undefined, row: 0 })); + const resourceNumbers = resourceKeys.map(numberOf); + const cellFor = (k: number): ThreadCell => { + const cell = cells[k]; + if (cell.storage === undefined) throw new Error(`Resource "${labelOf(resourceKeys[k])}" isn't in this world.`); + return cell; + }; + if (resourceKeys.length > 0) { + const values = own(); + resourceKeys.forEach((key, k) => { + Object.defineProperty(values, key, { + get: () => { + const { storage, row } = cellFor(k); + return storage === undefined ? undefined : readCell(storage, row); + }, + set: writes.has(key) ? (next: unknown) => { + const { storage, row } = cellFor(k); + if (storage === undefined) return; + writeCell(storage, row, next); + if (storage.tags.length !== 0) storage.tags[row] = ownTag; + stampChanged(storage, row, host.rows.tick); + } : undefined, + enumerable: true, + }); + }); + Object.defineProperty(value, "resources", { value: values, enumerable: true }); + } + const names = Object.keys(declaration.lookup ?? {}); + /** Its `lastRun` while it runs, which its lookups by entity compare with. */ + const lookupSince: Since = { value: 0 }; + /** Its lookups through indexes, whose views each run points at the index's arrays. */ + const threadIndexes: ThreadIndex[] = []; + if (names.length > 0) { + const lookups = own(); + for (const name of names) { + const declared = declaration.lookup?.[name] ?? {}; + const index = indexOfLookup(declared); + const record = records.get(name); + let found: unknown; + if (index === undefined || record === undefined) found = entityLookupOf(host, declared, lookupSince); + else { + const reads = declared.read ?? []; + const threadIndex = createThreadIndex(host.rows, host.archetypeAt, { + layouts: index.declaration.key.map(key => { + const layout = host.layoutOf(key); + if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema; import the module that registers it.`); + return layout; + }), + sorted: keptSorted(index.declaration), + unique: index.declaration.unique === true, + reads, + readIds: reads.map(numberOf), + watched: watchedBy(declared).length, + record, + }); + threadIndexes.push(threadIndex); + found = threadIndex.lookup; + } + Object.defineProperty(lookups, name, { value: found, enumerable: true }); + } + Object.defineProperty(value, "lookup", { value: lookups, enumerable: true }); + } + if (targets.length > 0) { + const reducers = own(); + targets.forEach((target, t) => { + const accumulators = reduced[t]; + const { operator, leaves } = target; + Object.defineProperty(reducers, target.key, { + value: (contribution: unknown): void => { + for (let i = 0; i < leaves.length; i++) accumulators[i] = combine(operator, accumulators[i] ?? NaN, contributed(contribution, leaves[i]?.path ?? [])); + }, + enumerable: true, + }); + }); + Object.defineProperty(value, "reduce", { value: reducers, enumerable: true }); + } + if (declaration.input !== undefined) value.input = undefined; + // A tail is terminal, so its context has no commands. + const tailValue: { input?: unknown } = own(); + for (const name of Object.getOwnPropertyNames(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (descriptor !== undefined && name !== "input") Object.defineProperty(tailValue, name, descriptor); + } + if (declaration.input !== undefined) tailValue.input = undefined; + Object.defineProperty(value, "commands", { value: host.commands, enumerable: true }); + + /** Resolves resource cells for a run: they move only at sync points. */ + const bind = (): void => { + const found = resourceKeys.length === 0 ? null : host.resourceRow(); + for (let k = 0; k < resourceKeys.length; k++) { + cells[k].storage = found?.archetype.storageById[resourceNumbers[k]]; + cells[k].row = found?.row ?? 0; + } + for (let t = 0; t < targets.length; t++) reduced[t].fill(identityOf(targets[t].operator)); + }; + + const conditionIs = (resources?.is ?? []).map(numberOf); + const conditionNot = (resources?.not ?? []).map(numberOf); + const conditions = [...conditionIs, ...conditionNot]; + const holding = (found: { readonly archetype: ThreadArchetype; readonly row: number } | null, id: number): boolean => { + const storage = found?.archetype.storageById[id]; + if (storage === undefined || found === null) return false; + const held = readCell(storage, found.row); + return held !== null && held !== false; + }; + const holds = (): boolean => { + if (conditions.length === 0) return true; + const found = host.resourceRow(); + return conditionIs.every(id => holding(found, id)) && !conditionNot.some(id => holding(found, id)); + }; + const resourcesChangedSince = (since: number): boolean => { + const found = host.resourceRow(); + if (found === null) return false; + for (const id of resourceChanged) { + const storage = found.archetype.storageById[id]; + if (storage !== undefined && storage.changed[found.row] > since) return true; + } + return false; + }; + + /** By archetype, its change filters, as of its view. */ + const filterCache = new Map(); + const filtersOf = (archetype: ThreadArchetype): readonly Filter[] => { + const cached = filterCache.get(archetype.id); + if (cached !== undefined && cached.archetype === archetype) return cached.filters; + const filters: Filter[] = []; + for (const id of addedIds) { + const storage = archetype.storageById[id]; + if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded }); + } + for (const id of changedIds) { + const storage = archetype.storageById[id]; + if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged }); + } + filterCache.set(archetype.id, { archetype, filters }); + return filters; + }; + + const runBody = (archetype: ThreadArchetype, count: number, since: number): void => { + const row = bindRow(archetype); + const subject = row.value; + const filters = filtersOf(archetype); + if (since < 0 || filters.length === 0) { + for (let index = 0; index < count; index++) { + row.index = index; + system.body(subject, value); + } + return; + } + forEachPassingRange(filters, since, 0, count, (start, end) => { + for (let index = start; index < end; index++) { + row.index = index; + system.body(subject, value); + } + }); + }; + + const outcome: Outcome = { executed: false, ran: false, error: undefined }; + /** Set once its system is removed on this thread: no row runs from then on. */ + let halted = false; + const run = (lastRun: number, input: unknown): Outcome => { + outcome.executed = outcome.ran = false; + outcome.error = undefined; + lookupSince.value = lastRun; + try { + // A system whose conditions don't hold is skipped, successfully, and keeps its `lastRun`. + if (!holds()) return outcome; + outcome.executed = true; + const resourcesChanged = resourceChanged.length > 0 && resourcesChangedSince(lastRun); + /** -1 passes every row. */ + const since = !filtered || resourcesChanged ? -1 : lastRun; + bind(); + if (threadIndexes.length !== 0) { + const buffer = host.refresh(); + for (let k = 0; k < threadIndexes.length; k++) threadIndexes[k].refresh(buffer); + } + if (declaration.input !== undefined) value.input = ownInput(input); + if (query === undefined) { + outcome.ran = since < 0; + if (outcome.ran) system.body(value); + return outcome; + } + if (since >= 0 && addedIds.length + changedIds.length === 0) return outcome; + outcome.ran = true; + refreshMatches(); + for (let m = 0; m < matches.length; m++) { + const archetype = host.archetypeAt(matches[m]); + if (archetype === undefined) continue; + const count = host.countOf(archetype); + if (count > 0) runBody(archetype, count, since); + } + } + catch (error) { + outcome.error = error; + } + return outcome; + }; + + // As a tail: its linear reads and resources, which a snapshot holds, and the objects it reads and writes, this + // thread's, which it reads from its mirror at the snapshot's rows. + const readKeys = query?.read ?? []; + const writeKeys = query?.write ?? []; + const linearReads: { readonly number: number; readonly layout: Columned }[] = []; + const constantReads: { readonly number: number; readonly layout: Layout }[] = []; + for (const key of readKeys) { + const layout = host.layoutOf(key); + // A string's snapshot column holds handles of records the world copied for it. + if (layout?.kind === "linear" || layout?.kind === "string") linearReads.push({ number: numberOf(key), layout }); + else if (layout?.kind === "constant") constantReads.push({ number: numberOf(key), layout }); + } + const objectKeys = [...new Set([...readKeys, ...writeKeys])].filter(key => host.layoutOf(key)?.kind === "object").map(numberOf); + const writtenObjects = new Set(writeKeys.map(numberOf)); + const snapshotResources = resourceKeys.flatMap((key, k) => { + const layout = host.layoutOf(key); + return (layout?.kind === "linear" || layout?.kind === "string") && !writes.has(key) ? [{ k, layout }] : []; + }); + const strides = linearReads.map(({ layout }) => layout.stride); + const resourceStrides = snapshotResources.map(({ layout }) => layout.stride); + /** Matched archetypes' views and counts as of the tail's head. */ + const tailArchetypes: (ThreadArchetype | undefined)[] = []; + const tailCounts: number[] = []; + + /** Where the tail finished reading through; the world waits for it once it took a snapshot. */ + const finish = (control: Int32Array, through: number): void => { + Atomics.store(control, doneWord, through); + if (Atomics.load(control, waitingWord) !== 0) Atomics.notify(control, doneWord); + }; + + const runRows = (archetype: ThreadArchetype, start: number, end: number, since: number): void => { + const row = bindRow(archetype); + const subject = row.value; + const filters = filtersOf(archetype); + if (since < 0 || filters.length === 0) { + for (let index = start; index < end && !halted; index++) { + row.index = index; + system.body(subject, tailValue); + } + return; + } + forEachPassingRange(filters, since, start, end, (first, last) => { + for (let index = first; index < last && !halted; index++) { + row.index = index; + system.body(subject, tailValue); + } + }); + }; + + /** Runs positions `from` to `to` where their rows are, over the archetypes as of the head. */ + const runPositions = (from: number, to: number, since: number): void => { + let base = 0; + for (let m = 0; m < tailCounts.length; m++) { + const count = tailCounts[m]; + const start = Math.max(from - 1 - base, 0); + const end = Math.min(to - 1 - base, count); + const archetype = tailArchetypes[m]; + if (end > start && archetype !== undefined) runRows(archetype, start, end, since); + base += count; + if (base >= to - 1) return; + } + }; + + /** Where each entity's row is in the mirror, for a mirror rebuilt past the tail's sync. */ + const locations = (component: number): Map => { + const found = new Map(); + for (const mirrored of host.mirror?.archetypes ?? []) { + const column = mirrored?.byComponent[component]; + if (mirrored === null || mirrored === undefined || column === undefined) continue; + for (let row = 0; row < mirrored.count; row++) found.set(mirrored.entities[row], { values: column.values, row }); + } + return found; + }; + + /** + * Runs the rest from the snapshot, from `position` on: its head too when `head`. Reads only the snapshot and this + * thread's mirror, never the world's storages, which may have moved since; objects it writes go back to the mirror + * after, and are stamped by the world. + */ + const runSnapshot = (control: Int32Array, position: number, head: boolean, sync: number, input: unknown, at: number, stamps: number[]): void => { + const buffer = host.refresh(); + const offset = Atomics.load(control, snapshotOffsetWord) >>> 0; + const rows = Atomics.load(control, snapshotRowsWord); + const flags = Atomics.load(control, snapshotFlagsWord); + const placed = snapshotLayout(Atomics.load(control, snapshotCapacityWord), strides, resourceStrides); + if (position === 0) tailSnapshots.late++; + else tailSnapshots.switched++; + const { mirror } = host; + const tick = host.rows.tick; + const bounded = mirror === null || mirror.through === (sync >>> 0); + const resourceArchetype = Atomics.load(control, snapshotResourceArchetypeWord); + const resourceRow = Atomics.load(control, snapshotResourceRowWord); + // Resources: structs from the snapshot, this thread's objects from its mirror's resource row. + const ownResources: { readonly k: number; readonly component: number; readonly storage: ThreadStorage }[] = []; + resourceKeys.forEach((key, k) => { + const layout = host.layoutOf(key); + const component = resourceNumbers[k]; + if (layout === undefined) return; + const copied = snapshotResources.findIndex(entry => entry.k === k); + if (copied >= 0) { + const stride = resourceStrides[copied]; + cells[k].storage = localStorage(component, layout, viewsOf(buffer, offset + placed.resources[copied], stride), [], 0, host.strings); + cells[k].row = 0; + return; + } + if (layout.kind === "object") { + const value = mirror?.archetypes[resourceArchetype]?.byComponent[component]?.values[resourceRow]; + const storage = localStorage(component, layout, emptyViews, [value], 1, host.strings); + cells[k].storage = storage; + cells[k].row = 0; + ownResources.push({ k, component, storage }); + return; + } + cells[k].storage = localStorage(component, layout, emptyViews, [], 0, host.strings); + cells[k].row = 0; + }); + if (head) { + if ((flags & headExecuted) === 0) return; + outcome.executed = true; + outcome.ran = (flags & headRan) !== 0; + if (declaration.input !== undefined) tailValue.input = ownInput(input); + if (!outcome.ran) return; + if (query === undefined) { + system.body(tailValue); + writeBackResources(ownResources, resourceArchetype, resourceRow, tick, at, stamps); + return; + } + } + const positions = new Uint32Array(buffer, offset + placed.positions, rows); + const archetypeIds = new Uint32Array(buffer, offset + placed.archetypes, rows); + const rowsOf = new Uint32Array(buffer, offset + placed.rows, rows); + const entities = new Float64Array(buffer, offset + placed.entities, rows); + const storageById: ThreadStorage[] = []; + linearReads.forEach(({ number, layout: linear }, k) => { + storageById[number] = localStorage(number, linear, viewsOf(buffer, offset + placed.columns[k], rows * linear.stride), [], 0, host.strings); + }); + for (const { number, layout: constant } of constantReads) storageById[number] = localStorage(number, constant, emptyViews, [], 0, host.strings); + const objects: { readonly number: number; readonly storage: ThreadStorage }[] = []; + for (const number of objectKeys) { + const values = new Array(rows); + const byEntity = bounded ? null : locations(number); + for (let i = 0; i < rows; i++) { + if (byEntity === null) values[i] = mirror?.archetypes[archetypeIds[i]]?.byComponent[number]?.values[rowsOf[i]]; + else { + const location = byEntity.get(entities[i]); + values[i] = location?.values[location.row]; + } + } + const storage = localStorage(number, objectLayout, emptyViews, values, writtenObjects.has(number) ? rows : 0, host.strings); + storageById[number] = storage; + if (writtenObjects.has(number)) objects.push({ number, storage }); + } + let first = 0; + while (first < rows && positions[first] < position) first++; + const row = bindRow({ id: -1, entities, storageById, record: -1, version: -1, mirrored: null, components: [], resource: false, meta: false }); + const subject = row.value; + try { + for (let index = first; index < rows && !halted; index++) { + row.index = index; + system.body(subject, tailValue); + } + } + finally { + // What it wrote goes back to the mirror, where its rows are now. + for (const { number, storage } of objects) { + const byEntity = bounded ? null : locations(number); + for (let i = first; i < rows; i++) { + if (storage.changed[i] !== tick) continue; + if (byEntity === null) { + const column = mirror?.archetypes[archetypeIds[i]]?.byComponent[number]; + if (column !== undefined) column.values[rowsOf[i]] = storage.values[i]; + } + else { + const location = byEntity.get(entities[i]); + if (location !== undefined) location.values[location.row] = storage.values[i]; + } + stamps.push(at, entities[i], number); + } + } + writeBackResources(ownResources, resourceArchetype, resourceRow, tick, at, stamps); + } + }; + + const writeBackResources = (resources: readonly { readonly component: number; readonly storage: ThreadStorage }[], archetype: number, row: number, tick: number, at: number, stamps: number[]): void => { + for (const { component, storage } of resources) { + if (storage.changed[0] !== tick) continue; + const column = host.mirror?.archetypes[archetype]?.byComponent[component]; + if (column !== undefined) column.values[row] = storage.values[0]; + stamps.push(at, -1, component); + } + }; + + /** + * `first` is its first claim when the caller made it; with `outcomeAt`, the word of `control` whether it ran, and + * took its tick, is stored at before its done word. + */ + const runTail = (lastRun: number, input: unknown, control: Int32Array, sync: number, at: number, stamps: number[], first?: number, outcomeAt?: number): Outcome => { + outcome.executed = outcome.ran = false; + outcome.error = undefined; + lookupSince.value = lastRun; + let claimed = first ?? Atomics.add(control, progressWord, tailChunk); + const end = Atomics.load(control, endWord); + try { + // An observer system on this thread looks up through indexes as the world wrote them into its records. + if (threadIndexes.length !== 0) { + const buffer = host.refresh(); + for (let k = 0; k < threadIndexes.length; k++) threadIndexes[k].refresh(buffer); + } + if (claimed >= snapshotted) { + runSnapshot(control, 0, true, sync, input, at, stamps); + return outcome; + } + // The head, within the first claim: conditions, resources and the archetypes as of the tail's sync. + if (!holds()) return outcome; + outcome.executed = true; + const resourcesChanged = resourceChanged.length > 0 && resourcesChangedSince(lastRun); + const since = !filtered || resourcesChanged ? -1 : lastRun; + bind(); + if (declaration.input !== undefined) tailValue.input = ownInput(input); + if (query === undefined) { + outcome.ran = since < 0; + if (outcome.ran) system.body(tailValue); + return outcome; + } + if (since >= 0 && addedIds.length + changedIds.length === 0) return outcome; + outcome.ran = true; + refreshMatches(); + tailArchetypes.length = tailCounts.length = 0; + for (let m = 0; m < matches.length; m++) { + const archetype = host.archetypeAt(matches[m]); + tailArchetypes.push(archetype); + tailCounts.push(archetype === undefined ? 0 : host.countOf(archetype)); + } + let from = 1; + for (;;) { + const to = Math.min(claimed + tailChunk, end); + if (from < to) runPositions(from, to, since); + if (to >= end) return outcome; + finish(control, to); + const next = to; + claimed = Atomics.add(control, progressWord, tailChunk); + if (claimed >= snapshotted) { + runSnapshot(control, next, false, sync, input, at, stamps); + return outcome; + } + from = claimed; + } + } + catch (error) { + outcome.error = error; + return outcome; + } + finally { + if (outcomeAt !== undefined) Atomics.store(control, outcomeAt, outcome.executed ? ranExecuted : ranSkipped); + finish(control, end); + } + }; + + const writeFolds = (completion: Float64Array, at: number): void => { + let offset = at; + for (const accumulators of reduced) for (let i = 0; i < accumulators.length; i++) completion[offset++] = accumulators[i]; + }; + + return { run, runTail, writeFolds, halt: () => { halted = true; } }; +}; + +/** A layout with a column in linear memory: a struct's, or a string's of handles. */ +type Columned = Extract; + +/** A storage over a snapshot's column, or of values only this thread holds, with ticks of its own for `ticked` rows. */ +const localStorage = (component: number, layout: Layout, views: Views, values: unknown[], ticked: number, strings: StringStore): ThreadStorage => { + const chunks = ticked === 0 ? 0 : (ticked >> chunkShift) + 1; + return { + component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), tags: noTags, ticked: ticked > 0, + added: ticked === 0 ? noTicks : new Float64Array(ticked), changed: ticked === 0 ? noTicks : new Float64Array(ticked), + chunkAdded: chunks === 0 ? noTicks : new Float64Array(chunks), chunkChanged: chunks === 0 ? noTicks : new Float64Array(chunks), + }; +}; + +/** A lookup returning `read` values keyed by component, from this thread's views. */ +const lookupOf = (host: RunnerHost, lookup: SystemLookup) => { + const read = (lookup.read ?? []).map(host.numberOf); + const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); + const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); + const keys = lookup.read ?? []; + return (entity: Entity): object | null => { + const table = host.refreshEntities(); + const index = locate(table, entity); + if (index < 0) return null; + const archetype = host.archetypeAt(table.archetype[index]); + if (archetype === undefined || archetype.resource) return null; + const { components } = archetype; + for (const id of read) if (!components.includes(id)) return null; + for (const id of has) if (!components.includes(id)) return null; + for (const id of not) if (components.includes(id)) return null; + const row = table.row[index]; + const values: { [key: Key]: unknown } = {}; + for (let i = 0; i < keys.length; i++) { + const storage = archetype.storageById[read[i]]; + values[keys[i]] = storage === undefined ? undefined : readCell(storage, row); + } + return values; + }; +}; + +/** A lookup reading nothing, from this thread's views: the shared empty row for every entity it finds. */ +const emptyLookupOf = (host: RunnerHost, lookup: SystemLookup) => { + const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); + const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); + return (entity: Entity): object | null => { + const table = host.refreshEntities(); + const index = locate(table, entity); + if (index < 0) return null; + const archetype = host.archetypeAt(table.archetype[index]); + if (archetype === undefined || archetype.resource) return null; + const { components } = archetype; + for (const id of has) if (!components.includes(id)) return null; + for (const id of not) if (components.includes(id)) return null; + return emptyRow; + }; +}; + +/** + * A lookup with `changed`, from this thread's views: one row reused, its `read` values keyed by component, and whether + * a component `watched` names may have changed in it after `since`. A GPU kernel's column stamps reach this thread as + * row ticks; a component keeping no ticks counts as changed. + */ +const changedLookupOf = (host: RunnerHost, lookup: SystemLookup, watched: readonly Key[], since: Since) => { + const read = (lookup.read ?? []).map(host.numberOf); + const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); + const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); + const ids = watched.map(host.numberOf); + const keys = lookup.read ?? []; + const found = changedRowOf(keys); + return (entity: Entity): object | null => { + const table = host.refreshEntities(); + const index = locate(table, entity); + if (index < 0) return null; + const archetype = host.archetypeAt(table.archetype[index]); + if (archetype === undefined || archetype.resource) return null; + const { components, storageById } = archetype; + for (const id of read) if (!components.includes(id)) return null; + for (const id of has) if (!components.includes(id)) return null; + for (const id of not) if (components.includes(id)) return null; + const row = table.row[index]; + for (let i = 0; i < keys.length; i++) { + const storage = storageById[read[i]]; + found[keys[i]] = storage === undefined ? undefined : readCell(storage, row); + } + let changed = since.value < 0; + for (let i = 0; i < ids.length && !changed; i++) { + const storage = storageById[ids[i]]; + changed = storage === undefined || !storage.ticked || storage.changed[row] > since.value; + } + found.changed = changed; + return found; + }; +}; + +/** The lookup by entity a declaration chooses, once: with `changed`, reading nothing, or reading values. */ +const entityLookupOf = (host: RunnerHost, lookup: SystemLookup, since: Since) => { + const watched = "index" in lookup ? undefined : lookup.changed; + if (watched !== undefined) return changedLookupOf(host, lookup, watched, since); + return (lookup.read ?? []).length === 0 ? emptyLookupOf(host, lookup) : lookupOf(host, lookup); +}; diff --git a/packages/ecs/src/implementation/world/thread-tasks.ts b/packages/ecs/src/implementation/world/thread-tasks.ts new file mode 100644 index 000000000..ddbfd0797 --- /dev/null +++ b/packages/ecs/src/implementation/world/thread-tasks.ts @@ -0,0 +1,75 @@ +import { lineWords } from './protocol.js'; + +// What the world's thread and a joined thread filling objects share to run that thread's systems (threads.md, "Systems +// on other threads"): a task block of control words, the task's payload and its completion, and a command ring the +// thread's systems write their segments into, as a pool worker's tasks do (`command-ring.ts`). +// +// One task is in flight per thread at a time: the world posts a task, the systems of one tier or of consecutive tiers +// all on that thread, and waits for it before the next tier, or the sync point, starts. + +/** + * The task block's words, each group on a line of its own by the thread writing it: the task the world posted last, + * then the task the thread finished last and the last it failed whole, running none of it, as when it couldn't view + * the memory, whose message says why; then the command ring's base and capacity in bytes and the payload's and + * completion's offsets and capacities, which the world sets while no task runs; then the ring's write cursor past its + * base, which the thread stores at each segment's end and the world resets at sync points, and the bytes its segments + * needed since, spills included, which the next sync point grows the ring to. + */ +export const taskPostedWord = 0; +export const taskDoneWord = lineWords; +export const taskFailedWord = lineWords + 1; +export const ringBaseWord = 2 * lineWords; +export const ringCapacityWord = 2 * lineWords + 1; +export const payloadOffsetWord = 2 * lineWords + 2; +export const payloadCapacityWord = 2 * lineWords + 3; +export const completionOffsetWord = 2 * lineWords + 4; +export const completionCapacityWord = 2 * lineWords + 5; +export const ringCursorWord = 3 * lineWords; +export const ringNeededWord = 3 * lineWords + 1; +export const taskBlockWords = 4 * lineWords; + +/** + * A task's payload, f64 words: its sequence; the sync the thread replays through before it runs; how many systems it + * runs; whether its input comes by message; then per system, in schedule order, its index on the thread, the tick it + * takes when it runs, its `lastRun`, its tier within the task, where its folds start in the completion, and for a + * tail the byte offset of its block (`tail-blocks.ts`), else 0. A task is all tails or none. + */ +export const payloadSequence = 0; +export const payloadSync = 1; +export const payloadCount = 2; +export const payloadInput = 3; +export const payloadSystems = 4; +export const systemFields = 6; +export const systemIndex = 0; +export const systemTick = 1; +export const systemLastRun = 2; +export const systemTier = 3; +export const systemFolds = 4; +export const systemTail = 5; + +/** f64 words of a payload for `count` systems. */ +export const payloadWords = (count: number): number => payloadSystems + count * systemFields; + +/** + * A task's completion, f64 words: whether a message follows it, with failures, spilled segments, the thread's numbers + * for keys its commands named for the first time, or the rows a tail wrote from its snapshot; then per system its + * flags and its segment's start and end past the ring's base; then the systems' folds, at the offsets the payload gives. + */ +export const completionMessage = 0; +export const completionSystems = 1; +export const resultFields = 3; +export const resultFlags = 0; +export const resultStart = 1; +export const resultEnd = 2; + +/** f64 words of a completion for `count` systems folding `folds` leaves in all. */ +export const completionWords = (count: number, folds: number): number => completionSystems + count * resultFields + folds; + +/** + * A system's result flags: it took its tick, its conditions holding; it ran its rows, so its folds count; it failed, + * its error in the message; its segment is in the message, past the ring's size. + */ +export const executedFlag = 1; +export const ranFlag = 2; +export const failedFlag = 4; +export const spilledFlag = 8; diff --git a/packages/ecs/src/implementation/world/threads.browser.test.ts b/packages/ecs/src/implementation/world/threads.browser.test.ts new file mode 100644 index 000000000..f63b2f018 --- /dev/null +++ b/packages/ecs/src/implementation/world/threads.browser.test.ts @@ -0,0 +1,46 @@ +import { workersCases } from "./workers.cases.js"; +import { layoutDirectoryCases } from "./layout-directory.cases.js"; +import { poolCases } from "./pool.cases.js"; +import { poolSizeCases } from "./pool-size.cases.js"; +import { dispatchCases } from "./dispatch.cases.js"; +import { reductionsCases } from "./reductions.cases.js"; +import { observedWorkersCases } from "./observed-workers.cases.js"; +import { upWorkersCases } from "./up-workers.cases.js"; +import { portsCases } from "./ports.cases.js"; +import { commandRingsCases } from "./command-rings.cases.js"; +import { joiningCases } from "./joining.cases.js"; +import { remoteObjectsCases } from "./remote-objects.cases.js"; +import { threadSystemsCases } from "./thread-systems.cases.js"; +import { remoteFinalizersCases } from "./remote-finalizers.cases.js"; +import { tailsCases } from "./tails.cases.js"; +import { transfersCases } from "./transfers.cases.js"; +import { lifetimeCases } from "./lifetime.cases.js"; +import { removalCases } from "./removal.cases.js"; +import { indexWorkersCases } from "./index-workers.cases.js"; +import { indexBuildCases } from "./index-build.cases.js"; +import { entityLookupWorkersCases } from "./entity-lookup-workers.cases.js"; +import { observerJoinedCases } from "./observer-joined.cases.js"; + +// Real workers in chromium: the cases over the fixtures' sources, as node runs them built. +workersCases(() => import("./workers.fixture.js")); +layoutDirectoryCases(() => import("./layout-directory.fixture.js")); +poolCases(() => import("./pool.fixture.js")); +poolSizeCases(() => import("./pool-size.fixture.js")); +dispatchCases(() => import("./dispatch.fixture.js")); +reductionsCases(() => import("./reductions.fixture.js")); +observedWorkersCases(() => import("./observed-workers.fixture.js")); +upWorkersCases(() => import("./up-workers.fixture.js")); +portsCases(() => import("./ports.fixture.js")); +commandRingsCases(() => import("./command-rings.fixture.js")); +joiningCases(() => import("./joining.fixture.js")); +remoteObjectsCases(() => import("./remote-objects.fixture.js")); +threadSystemsCases(() => import("./thread-systems.fixture.js")); +remoteFinalizersCases(() => import("./remote-finalizers.fixture.js")); +tailsCases(() => import("./tails.fixture.js")); +transfersCases(() => import("./transfers.fixture.js")); +lifetimeCases(() => import("./lifetime.fixture.js")); +removalCases(() => import("./removal.fixture.js")); +indexWorkersCases(() => import("./index-workers.fixture.js")); +indexBuildCases(() => import("./index-build.fixture.js")); +entityLookupWorkersCases(() => import("./entity-lookup-workers.fixture.js")); +observerJoinedCases(() => import("./observer-joined.fixture.js")); diff --git a/packages/ecs/src/implementation/world/threads.test.ts b/packages/ecs/src/implementation/world/threads.test.ts new file mode 100644 index 000000000..c99e5dfe2 --- /dev/null +++ b/packages/ecs/src/implementation/world/threads.test.ts @@ -0,0 +1,73 @@ +import { builtFixture } from "./fixtures.test-helper.js"; +import type * as Workers from "./workers.fixture.js"; +import { workersCases } from "./workers.cases.js"; +import type * as LayoutDirectory from "./layout-directory.fixture.js"; +import { layoutDirectoryCases } from "./layout-directory.cases.js"; +import type * as Pool from "./pool.fixture.js"; +import { poolCases } from "./pool.cases.js"; +import type * as PoolSize from "./pool-size.fixture.js"; +import { poolSizeCases } from "./pool-size.cases.js"; +import type * as Dispatch from "./dispatch.fixture.js"; +import { dispatchCases } from "./dispatch.cases.js"; +import type * as Reductions from "./reductions.fixture.js"; +import { reductionsCases } from "./reductions.cases.js"; +import type * as ObservedWorkers from "./observed-workers.fixture.js"; +import { observedWorkersCases } from "./observed-workers.cases.js"; +import type * as UpWorkers from "./up-workers.fixture.js"; +import { upWorkersCases } from "./up-workers.cases.js"; +import type * as Ports from "./ports.fixture.js"; +import { portsCases } from "./ports.cases.js"; +import type * as CommandRings from "./command-rings.fixture.js"; +import { commandRingsCases } from "./command-rings.cases.js"; +import type * as Joining from "./joining.fixture.js"; +import { joiningCases } from "./joining.cases.js"; +import type * as RemoteObjects from "./remote-objects.fixture.js"; +import { remoteObjectsCases } from "./remote-objects.cases.js"; +import type * as ThreadSystems from "./thread-systems.fixture.js"; +import { threadSystemsCases } from "./thread-systems.cases.js"; +import type * as RemoteFinalizers from "./remote-finalizers.fixture.js"; +import { remoteFinalizersCases } from "./remote-finalizers.cases.js"; +import type * as Tails from "./tails.fixture.js"; +import { tailsCases } from "./tails.cases.js"; +import type * as Transfers from "./transfers.fixture.js"; +import { transfersCases } from "./transfers.cases.js"; +import type * as Lifetime from "./lifetime.fixture.js"; +import { lifetimeCases } from "./lifetime.cases.js"; +import type * as Removal from "./removal.fixture.js"; +import { removalCases } from "./removal.cases.js"; +import type * as IndexWorkers from "./index-workers.fixture.js"; +import { indexWorkersCases } from "./index-workers.cases.js"; +import type * as IndexBuild from "./index-build.fixture.js"; +import { indexBuildCases } from "./index-build.cases.js"; +import type * as EntityLookupWorkers from "./entity-lookup-workers.fixture.js"; +import { entityLookupWorkersCases } from "./entity-lookup-workers.cases.js"; +import type * as ObserverJoined from "./observer-joined.fixture.js"; +import { observerJoinedCases } from "./observer-joined.cases.js"; +import type * as ObserverContained from "./observer-contained.fixture.js"; +import { observerContainedCases } from "./observer-contained.cases.js"; + +// Real worker threads in node: the cases over fixtures `vitest.fixtures.ts` built, each typed as its source, which it's +// built from. +workersCases((): Promise => builtFixture("workers")); +layoutDirectoryCases((): Promise => builtFixture("layout-directory")); +poolCases((): Promise => builtFixture("pool")); +poolSizeCases((): Promise => builtFixture("pool-size")); +dispatchCases((): Promise => builtFixture("dispatch")); +reductionsCases((): Promise => builtFixture("reductions")); +observedWorkersCases((): Promise => builtFixture("observed-workers")); +upWorkersCases((): Promise => builtFixture("up-workers")); +portsCases((): Promise => builtFixture("ports")); +commandRingsCases((): Promise => builtFixture("command-rings")); +joiningCases((): Promise => builtFixture("joining")); +remoteObjectsCases((): Promise => builtFixture("remote-objects")); +threadSystemsCases((): Promise => builtFixture("thread-systems")); +remoteFinalizersCases((): Promise => builtFixture("remote-finalizers")); +tailsCases((): Promise => builtFixture("tails")); +transfersCases((): Promise => builtFixture("transfers")); +lifetimeCases((): Promise => builtFixture("lifetime")); +removalCases((): Promise => builtFixture("removal")); +indexWorkersCases((): Promise => builtFixture("index-workers")); +indexBuildCases((): Promise => builtFixture("index-build")); +entityLookupWorkersCases((): Promise => builtFixture("entity-lookup-workers")); +observerJoinedCases((): Promise => builtFixture("observer-joined")); +observerContainedCases((): Promise => builtFixture("observer-contained")); diff --git a/packages/ecs/src/implementation/world/ticks.browser.test.ts b/packages/ecs/src/implementation/world/ticks.browser.test.ts new file mode 100644 index 000000000..025cf136e --- /dev/null +++ b/packages/ecs/src/implementation/world/ticks.browser.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { Resource } from "../../interface/resource/resource.js"; +import { always } from "../../interface/resources/always.js"; +import { clock } from "../../interface/resources/clock.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { readResource } from "./reads.test-helper.js"; +import { workers } from "../../interface/resources/workers.js"; + +const frames = Symbol("ticksBrowserTest/frames"); +declare module "@adobe/ecs" { + export interface Resources { + [frames]: { type: number }; + } +} +Resource.register(frames, { type: { type: "number", default: 0 } }); + +const count = System.create({ name: "ticksBrowserTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); +const start = System.create({ name: "ticksBrowserTest/start", resources: { write: [always] } }, ({ resources }) => { resources[always] = true; }); + +describe("a frame cadence", () => { + it("ticks on animation frames, with their time", async () => { + const original = globalThis.requestAnimationFrame; + let requested = 0; + globalThis.requestAnimationFrame = callback => { + requested++; + return original(callback); + }; + const world = createWorld({ [workers]: 0 }); + try { + world.add(count); + await world.run(start); + while (await readResource(world, frames) < 3) await new Promise(resolve => original(resolve)); + expect(requested).toBeGreaterThanOrEqual(3); + const { now, delta } = await readResource(world, clock); + expect(now).toBeGreaterThan(0); + expect(delta).toBeGreaterThan(0); + } + finally { + globalThis.requestAnimationFrame = original; + await world.dispose(); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts new file mode 100644 index 000000000..7cef31c7a --- /dev/null +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { Resource, System, clock, workers } from "../../index.js"; +import { createWorld } from "./create.js"; + +// The cases of ticks that read through observers are in `observe-ticks-cases.test.ts`. + +const frames = Symbol("ticksTest/frames"); +declare module "@adobe/ecs" { + interface Resources { + [frames]: { type: number }; + } +} +Resource.register(frames, { type: { type: "number", default: 0 } }); + +const count = System.create({ name: "ticksTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); + +describe("ticks", () => { + it("reject a system that conflicts with one added with no order between them, and one with input", async () => { + const other = System.create({ name: "ticksTest/other", resources: { write: [frames] } }, ({ resources }) => { resources[frames] = 0; }); + const withInput = System.create({ name: "ticksTest/withInput", input: { type: "object", properties: { by: { type: "number" } } } }, () => {}); + await using world = createWorld({ [workers]: 0 }); + world.add(count); + expect(() => world.add(other)).toThrow('both access "ticksTest/frames"'); + // @ts-expect-error a system with input can't be added + expect(() => world.add(withInput)).toThrow("takes input"); + }); +}); + +describe("World.create", () => { + it("rejects starting a resource only the world writes", () => { + // @ts-expect-error clock is written only by the world + expect(() => createWorld({ [clock]: { now: 0, delta: 0, tick: 0 } })).toThrow('"clock" is written only by the world'); + }); +}); diff --git a/packages/ecs/src/implementation/world/traits.test.ts b/packages/ecs/src/implementation/world/traits.test.ts new file mode 100644 index 000000000..d3d59aebb --- /dev/null +++ b/packages/ecs/src/implementation/world/traits.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import { count, read } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +const score = Symbol("traitsTest/score"); +const slot = Symbol("traitsTest/slot"); +const ready = Symbol("traitsTest/ready"); +const parent = Symbol("traitsTest/parent"); + +declare module "@adobe/ecs" { + export interface Components { + [score]: { type: number }; + [slot]: { type: number; derived: true }; + [ready]: { type: true; within: typeof slot }; + [parent]: { type: Entity; reference: "owner" }; + } +} + +Component.register(score, { type: { type: "number" } }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Component.register(ready, { type: { const: true }, within: slot }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); + +describe("traits", () => { + it("keep derived components out of commands, for untyped callers too", async () => { + const errors: string[] = []; + const derived = System.create({ name: "traitsTest/derived" }, ({ commands }) => { + const entity = commands.spawn([score])({ [score]: 1 }); + const attempts = [ + // @ts-expect-error derived components are added only by rules + () => commands.spawn([slot])({ [slot]: 1 }), + // @ts-expect-error derived components are added only by rules + () => commands.add(entity, slot, 1), + // @ts-expect-error derived components are removed only by rules + () => commands.remove(entity, slot), + ]; + for (const attempt of attempts) { + try { attempt(); } + catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } + } + }); + const world = createWorld({ [workers]: 0 }); + await world.run(derived); + expect(errors).toEqual(Array.from({ length: 3 }, () => `"traitsTest/slot" is derived, so only rules add and remove it.`)); + expect(await count(world, { required: [score] })).toBe(1); + await world.dispose(); + }); + + it("leave index 0 to Entity.none, which no lookup finds", async () => { + let first = Entity.none; + let found: unknown = undefined; + const spawn = System.create({ name: "traitsTest/spawn" }, ({ commands }) => { first = commands.spawn([score])({ [score]: 1 }); }); + const lookup = System.create({ name: "traitsTest/lookup", lookup: { scores: { read: [score] } } }, ({ lookup: { scores } }) => { found = scores(Entity.none); }); + const world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(lookup); + expect(first).not.toBe(Entity.none); + expect(found).toBeNull(); + expect(await read(world, Entity.none, score)).toBeNull(); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/transfers-worker.fixture.ts b/packages/ecs/src/implementation/world/transfers-worker.fixture.ts new file mode 100644 index 000000000..1fbd48eb0 --- /dev/null +++ b/packages/ecs/src/implementation/world/transfers-worker.fixture.ts @@ -0,0 +1,114 @@ +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { requireDefinition } from "../../interface/registry/index.js"; +import type { World } from "../../interface/world/world.js"; +import { join } from "./create.js"; +import { asEntity } from "./entities.js"; +import { peersOf } from "./join.js"; +import { scopeChannel } from "./port.js"; +import { attachHere, attachThere, greetThere, kept, mark, storeThere, stored, transferSystems } from "./transfers.test-helper.js"; + +/** + * A thread joining a world through the port it's sent: the filler claims "transfersTest", whose systems run here, and + * the loader claims nothing and runs systems with Transferable input, moving it to their threads. The filler blocks + * its event loop when asked, until the asker sets the word it gave, so what the loader moves there waits to be received. + */ + +type Port = MessagePort | NodeMessagePort; +export type ToTransfersWorker = + | { readonly kind: "join"; readonly port: Port; readonly threads: readonly "transfersTest"[] } + | { readonly kind: "block"; readonly word: Int32Array } + | { readonly kind: "stored" } + | { readonly kind: "moves"; readonly count: number; readonly bytes: number } + | { readonly kind: "late" } + | { readonly kind: "settled" } + | { readonly kind: "drops" }; +export type FromTransfersWorker = + | { readonly kind: "joined" } + | { readonly kind: "blocking" } + | { readonly kind: "stored"; readonly stored: readonly (readonly [number, number, number])[]; readonly widths: readonly number[] } + | { readonly kind: "moved"; readonly detached: number; readonly greeted: unknown; readonly runs: readonly number[] } + | { readonly kind: "posted"; readonly detached: boolean } + | { readonly kind: "settled"; readonly runs: readonly number[] } + | { readonly kind: "dropped"; readonly runs: readonly number[]; readonly closed: boolean; readonly widths: readonly number[] }; + +const channel = scopeChannel(); +let remote: World | null = null; +let posted: Promise[] = []; + +const later = (milliseconds = 0): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)); + +const bufferOf = (first: number, bytes: number): ArrayBuffer => { + const buffer = new ArrayBuffer(bytes); + new Uint8Array(buffer)[0] = first; + return buffer; +}; + +/** Resolves once a port's other end closed, or with false after `milliseconds`. */ +const closed = (port: MessagePort, milliseconds = 20000): Promise => new Promise(resolve => { + port.addEventListener("close", () => resolve(true)); + port.start(); + setTimeout(() => resolve(false), milliseconds); +}); + +/** A bitmap, where the platform has them: a browser's. */ +const bitmap = async (): Promise => + typeof createImageBitmap === "function" ? createImageBitmap(new ImageData(4, 4)) : null; + +const respond = async (message: Exclude): Promise => { + if (message.kind === "join") { + for (const system of transferSystems) requireDefinition(system); + remote = join(message.port, { threads: message.threads }); + // Joined once the world gave it a channel to the other joined thread. + while ((peersOf.get(remote)?.() ?? 0) === 0) await later(1); + return { kind: "joined" }; + } + if (message.kind === "stored") { + return { kind: "stored", stored: stored.map(({ bytes, first, order }) => [bytes, first, order]), widths: kept.map(value => typeof ImageBitmap === "function" && value instanceof ImageBitmap ? value.width : -1) }; + } + if (remote === null) throw new Error("transfers: the worker hasn't joined."); + const world = remote; + if (message.kind === "moves") { + const buffers = Array.from({ length: message.count }, (_, k) => bufferOf(k + 1, message.bytes)); + const runs = buffers.map(buffer => world.run(storeThere, { buffer })); + const detached = buffers.filter(buffer => buffer.byteLength === 0).length; + const ports = new MessageChannel(); + const greeted = new Promise(resolve => { + ports.port2.onmessage = event => resolve(event.data); + }); + runs.push(world.run(greetThere, { port: ports.port1 })); + const numbers = await Promise.all(runs); + const greeting = await greeted; + ports.port2.close(); + return { kind: "moved", detached, greeted: greeting, runs: numbers }; + } + if (message.kind === "late") { + const buffer = bufferOf(9, 1 << 20); + posted = [world.run(storeThere, { buffer }), world.run(mark, { value: 1 }), world.run(mark, { value: 2 }), world.run(mark, { value: 3 })]; + return { kind: "posted", detached: buffer.byteLength === 0 }; + } + if (message.kind === "settled") { + return { kind: "settled", runs: await Promise.all(posted) }; + } + // Commands giving a dead entity values: they drop, and each value is released on its thread. + const dead = asEntity(123456); + const ports = new MessageChannel(); + const closing = closed(ports.port2); + const runs = [await world.run(attachThere, { entity: dead, value: ports.port1 })]; + const there = await bitmap(); + const here = await bitmap(); + if (there !== null && here !== null) { + runs.push(await world.run(attachThere, { entity: dead, value: there })); + runs.push(await world.run(attachHere, { entity: dead, value: here })); + } + // Moved, so detached here: zero wide. + const widths = there === null || here === null ? [] : [there.width, here.width]; + return { kind: "dropped", runs, closed: await closing, widths }; +}; + +channel.onMessage(async message => { + if ("protocol" in message) return; + if (message.kind !== "block") return channel.post(await respond(message)); + // The reply goes out first; then this thread's event loop is blocked until the asker sets the word. + channel.post({ kind: "blocking" }); + Atomics.wait(message.word, 0, 0); +}); diff --git a/packages/ecs/src/implementation/world/transfers.cases.ts b/packages/ecs/src/implementation/world/transfers.cases.ts new file mode 100644 index 000000000..088c3ea42 --- /dev/null +++ b/packages/ecs/src/implementation/world/transfers.cases.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./transfers.fixture.js"; + +/** Transfers between joined workers: the cases, over the fixture built for node or from source in chromium. */ +export const transfersCases = (load: () => Promise) => describe("transfers between joined workers", () => { + it("move runs' Transferable input to the joined thread their system runs on, in call order", async () => { + const fixture = await load(); + const { moved, received } = await fixture.moves(10, 1 << 20); + expect(moved).toEqual({ kind: "moved", detached: 10, greeted: "anchor", failures: 0 }); + expect(received).toEqual(Array.from({ length: 10 }, (_, k) => [1 << 20, k + 1, k])); + }, 30000); + it("hold a run whose input hasn't been received yet, and that thread's later runs, while the world's own runs go on", async () => { + const fixture = await load(); + const seen = await fixture.late(); + expect(seen.posted).toEqual({ kind: "posted", detached: true }); + expect(seen.worldRun).toBe("finished"); + expect(seen.orderWhileBlocked).toBe(0); + expect(seen.settled).toEqual({ kind: "settled", failures: 0 }); + expect({ stores: seen.stores, marks: seen.marks }).toEqual({ stores: [0], marks: [[1, 1], [2, 2], [3, 3]] }); + }, 30000); + it("release a value whose command drops, on its thread", async () => { + const fixture = await load(); + const { dropped, there, here } = await fixture.drops(); + // Where the platform has bitmaps, a browser, one dropped there and one here, both closed; where it tells a port + // its other end closed, node, the port dropped there. + const bitmaps = there.length > 1; + expect(dropped).toEqual({ kind: "dropped", dropped: bitmaps ? [1, 1, 1] : [1], closed: bitmaps ? expect.any(Boolean) : true, widths: bitmaps ? [0, 0] : [] }); + expect({ there, here }).toEqual(bitmaps ? { there: [-1, 0], here: [0] } : { there: [-1], here: [] }); + }, 30000); +}); diff --git a/packages/ecs/src/implementation/world/transfers.fixture.ts b/packages/ecs/src/implementation/world/transfers.fixture.ts new file mode 100644 index 000000000..9da48befd --- /dev/null +++ b/packages/ecs/src/implementation/world/transfers.fixture.ts @@ -0,0 +1,147 @@ +/// +import { Observe } from "../../observe/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import type { FromTransfersWorker, ToTransfersWorker } from "./transfers-worker.fixture.js"; +import { idle, kept, marks, order, stored } from "./transfers.test-helper.js"; +import { reportOf } from "./reports.test-helper.js"; + +/** + * Scenarios of transfers between joined workers, run built in node and from source in chromium: the world here, a + * filler worker claiming "transfersTest" and a loader worker claiming nothing, which moves runs' Transferable input to + * the threads their systems run on. + */ + +type Worker = { readonly ask: (message: ToTransfersWorker, transfer?: readonly unknown[]) => Promise; readonly terminate: () => Promise }; + +const workerFile = "./transfers-worker.fixture.js"; + +const start = (): Worker => { + if (typeof globalThis.Worker === "function") { + const worker = new globalThis.Worker(new URL("./transfers-worker.fixture.js", import.meta.url), { type: "module" }); + return { + ask: (message, transfer = []) => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => resolve(event.data); + worker.postMessage(message, { transfer: transfer.filter(port => port instanceof MessagePort) }); + }), + terminate: async () => worker.terminate(), + }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + return { + ask: (message, transfer = []) => new Promise(resolve => { + worker.once("message", resolve); + worker.postMessage(message, transfer.filter(port => port instanceof threads.MessagePort)); + }), + terminate: async () => { await worker.terminate(); }, + }; +}; + +/** A port of a channel this world shares the other end of. */ +const shared = (world: CreatedWorld) => { + if (typeof globalThis.Worker === "function") { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + return port2; + } + const { port1, port2 } = new (globalThis.process.getBuiltinModule("node:worker_threads").MessageChannel)(); + world.share(port1); + return port2; +}; + +/** The world, its filler and its loader, each with a channel to the other. */ +const joined = async () => { + stored.length = marks.length = kept.length = 0; + const world = createWorld({ [workers]: 0 }); + const filler = start(); + const loader = start(); + const loaderPort = shared(world); + const fillerPort = shared(world); + await Promise.all([ + loader.ask({ kind: "join", port: loaderPort, threads: [] }, [loaderPort]), + filler.ask({ kind: "join", port: fillerPort, threads: ["transfersTest"] }, [fillerPort]), + ]); + const end = async (): Promise => { + await world.dispose(); + await Promise.all([filler.terminate(), loader.terminate()]); + }; + return { world, filler, loader, end }; +}; + +const storedOf = async (worker: Worker) => { + const reply = await worker.ask({ kind: "stored" }); + if (reply.kind !== "stored") throw new Error("transfers: the filler didn't say what it stored."); + return reply; +}; + +/** The loader moving `count` buffers of `bytes` each, and a MessagePort, to the filler, whose systems take them. */ +export const moves = async (count: number, bytes: number) => { + const { world, filler, loader, end } = await joined(); + const reply = await loader.ask({ kind: "moves", count, bytes }); + if (reply.kind !== "moved") throw new Error("transfers: the loader didn't say what moved."); + const { runs, ...moved } = reply; + const failures = await failuresOf(world, runs); + const { stored: received } = await storedOf(filler); + await end(); + return { moved: { ...moved, failures }, received }; +}; + +/** How many failures the reports of a joined thread's runs list, read on the world's thread, which holds them. */ +const failuresOf = async (world: CreatedWorld, runs: readonly number[]): Promise => { + let failures = 0; + for (const run of runs) failures += (await reportOf(world, run)).failures.length; + return failures; +}; + +/** Resolves with what `promise` does, or `otherwise` after `milliseconds`. */ +const within = (promise: Promise, milliseconds: number, otherwise: T): Promise => new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(otherwise), milliseconds); + promise.then(value => { + clearTimeout(timer); + resolve(value); + }, (error: unknown) => { + clearTimeout(timer); + reject(error); + }); +}); + +/** + * The loader's run moving a buffer to the filler while the filler's event loop is blocked, until this releases it, + * then three runs of a system on the world's thread: whether the world's own run finished while the filler was + * blocked, rather than waiting for it, the order then, and the order each of the loader's runs saw once it wasn't. + */ +export const late = async () => { + const { world, filler, loader, end } = await joined(); + const block = new Int32Array(new SharedArrayBuffer(4)); + await filler.ask({ kind: "block", word: block }); + const posted = await loader.ask({ kind: "late" }); + // The filler stays blocked until it's released, so a world that waited for it never finishes this run in time. + const worldRun = await within(world.run(idle).then(() => "finished"), 10000, "waited"); + // Time for the loader's held runs to run, wrongly, while the filler can't take its input. + await new Promise(resolve => setTimeout(resolve, 100)); + const orderWhileBlocked = await Observe.toPromise(Observe.resource(world, order)); + Atomics.store(block, 0, 1); + Atomics.notify(block, 0); + const reply = await loader.ask({ kind: "settled" }); + if (reply.kind !== "settled") throw new Error("transfers: the loader didn't say how its runs settled."); + const settled = { kind: reply.kind, failures: await failuresOf(world, reply.runs) }; + const { stored: received } = await storedOf(filler); + await end(); + return { posted, worldRun, orderWhileBlocked, settled, stores: received.map(([, , seen]) => seen), marks: marks.map(({ value, order }) => [value, order]) }; +}; + +/** Commands giving values the loader moved to a dead entity, on the filler and here: each released on its thread. */ +export const drops = async () => { + const { world, filler, loader, end } = await joined(); + const reply = await loader.ask({ kind: "drops" }); + if (reply.kind !== "dropped") throw new Error("transfers: the loader didn't say what dropped."); + const { runs, ...rest } = reply; + const counts: number[] = []; + for (const run of runs) counts.push((await reportOf(world, run)).dropped); + const dropped = { ...rest, dropped: counts }; + const { widths } = await storedOf(filler); + const here = kept.map(value => typeof ImageBitmap === "function" && value instanceof ImageBitmap ? value.width : -1); + await end(); + return { dropped, there: widths, here }; +}; diff --git a/packages/ecs/src/implementation/world/transfers.test-helper.ts b/packages/ecs/src/implementation/world/transfers.test-helper.ts new file mode 100644 index 000000000..1801970fb --- /dev/null +++ b/packages/ecs/src/implementation/world/transfers.test-helper.ts @@ -0,0 +1,111 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import { System } from "../../interface/system/system.js"; + +// Keys and systems every thread of the transfer tests imports: systems on thread "transfersTest", which a joined thread +// fills, and on the world's, taking Transferable values in their input, and `transfer` components holding them. +// An input field's schema `{ allOf: [] }` takes any value, typed `unknown`, which each body narrows. + +export const anchor = Symbol("transfersTest/anchor"); +export const order = Symbol("transfersTest/order"); +export const port = Symbol("transfersTest/port"); +export const bitmap = Symbol("transfersTest/bitmap"); +export const portHere = Symbol("transfersTest/portHere"); +export const bitmapHere = Symbol("transfersTest/bitmapHere"); +export const parent = Symbol("transfersTest/parent"); +export const depth = Symbol("transfersTest/depth"); +declare module "@adobe/ecs" { + export interface Threads { + transfersTest: true; + } + export interface Components { + [port]: { type: MessagePort; thread: "transfersTest"; transfer: true }; + [bitmap]: { type: ImageBitmap; thread: "transfersTest"; transfer: true }; + [portHere]: { type: MessagePort; transfer: true }; + [bitmapHere]: { type: ImageBitmap; transfer: true }; + [parent]: { type: Entity; reference: "owner" }; + [depth]: { type: number }; + } + export interface Resources { + [anchor]: { type: { readonly text: string }; thread: "transfersTest" }; + [order]: { type: number }; + } +} +/** An object resource of "transfersTest", which places the systems reading it there. */ +Resource.register(anchor, { type: { type: "object", properties: { text: { type: "string" } }, default: { text: "anchor" } }, thread: "transfersTest" }); +/** Counts the runs that read it, in the order they ran. */ +Resource.register(order, { type: { type: "integer", default: 0 } }); +Component.register(port, { thread: "transfersTest", transfer: true }); +Component.register(bitmap, { thread: "transfersTest", transfer: true }); +Component.register(portHere, { transfer: true }); +Component.register(bitmapHere, { transfer: true }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(depth, { type: { type: "number" } }); + +const any = { allOf: [] } as const; + +/** What a system was given, on the thread it ran on: an ArrayBuffer's length and first byte, and the run's order. */ +export type Stored = { readonly bytes: number; readonly first: number; readonly order: number; readonly value: unknown }; +export const stored: Stored[] = []; +/** The values systems gave components, on the thread they ran on. */ +export const kept: unknown[] = []; + +const store = (value: unknown, resources: { [order]: number }): void => { + const bytes = value instanceof ArrayBuffer ? value.byteLength : -1; + stored.push({ bytes, first: bytes > 0 && value instanceof ArrayBuffer ? new Uint8Array(value)[0] : -1, order: resources[order]++, value }); +}; + +/** On "transfersTest": records the buffer it's given. */ +export const storeThere = System.create({ name: "transfersTest/storeThere", input: { type: "object", properties: { buffer: any } }, resources: { read: [anchor], write: [order] } }, ({ input, resources }) => { + if (resources[anchor].text === "anchor") store(input.buffer, resources); +}); +/** On the world's thread: records the buffer it's given. */ +export const storeHere = System.create({ name: "transfersTest/storeHere", input: { type: "object", properties: { buffer: any } }, resources: { write: [order] } }, ({ input, resources }) => { + store(input.buffer, resources); +}); +/** On the world's thread: what a run from a joined thread saw of the order, by its value. */ +export const marks: { readonly value: number; readonly order: number }[] = []; +export const mark = System.create({ name: "transfersTest/mark", input: { type: "object", properties: { value: { type: "number" } } }, resources: { write: [order] } }, ({ input, resources }) => { + marks.push({ value: input.value, order: resources[order]++ }); +}); +/** On "transfersTest": gives an entity the port or bitmap it's given. */ +export const attachThere = System.create({ + name: "transfersTest/attachThere", input: { type: "object", properties: { entity: Entity.schema, value: any } }, resources: { read: [anchor] }, +}, ({ input, resources, commands }) => { + if (resources[anchor].text !== "anchor") return; + kept.push(input.value); + if (input.value instanceof MessagePort) commands.add(input.entity, port, input.value); + else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmap, input.value); +}); +/** On the world's thread: gives an entity the port or bitmap it's given. */ +export const attachHere = System.create({ name: "transfersTest/attachHere", input: { type: "object", properties: { entity: Entity.schema, value: any } } }, ({ input, commands }) => { + kept.push(input.value); + if (input.value instanceof MessagePort) commands.add(input.entity, portHere, input.value); + else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmapHere, input.value); +}); +/** On "transfersTest": says hello through the port it's given, which only moving it there, not copying it, gives. */ +export const greetThere = System.create({ name: "transfersTest/greetThere", input: { type: "object", properties: { port: any } }, resources: { read: [anchor] } }, ({ input, resources }) => { + if (input.port instanceof MessagePort) input.port.postMessage(resources[anchor].text); +}); + +/** On the world's thread: spawns an entity holding the port it's given. */ +export const spawnHere = System.create({ name: "transfersTest/spawnHere", input: { type: "object", properties: { value: any } } }, ({ input, commands }) => { + if (input.value instanceof MessagePort) commands.spawn([portHere])({ [portHere]: input.value }); +}); + +/** + * On "transfersTest", declaring `up`, which runs only on the world's thread: once a joined thread fills + * "transfersTest", it fails to register, so a run of it never runs. + */ +export const unregistered = System.create({ + name: "transfersTest/unregistered", input: { type: "object", properties: { value: any } }, components: { read: [port], write: [depth] }, up: { through: parent, read: [depth] }, +}, (row, { input, up }) => { + row[depth] = (up === null ? 0 : up[depth]) + (row[port] === input.value ? 1 : 0); +}); + +/** On the world's thread: nothing, a run of its own. */ +export const idle = System.create({ name: "transfersTest/idle" }, () => {}); + +/** Every system the joined threads run or place, which they import so the world can place them there. */ +export const transferSystems: readonly { readonly name: string }[] = [storeThere, storeHere, mark, attachThere, attachHere, spawnHere, greetThere, unregistered]; diff --git a/packages/ecs/src/implementation/world/transfers.test.ts b/packages/ecs/src/implementation/world/transfers.test.ts new file mode 100644 index 000000000..efa2ec1c9 --- /dev/null +++ b/packages/ecs/src/implementation/world/transfers.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import type { Observe } from "../../observe/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { asEntity } from "./entities.js"; +import { peersOf } from "./join.js"; +import { attachHere, attachThere, greetThere, kept, mark, marks, spawnHere, storeHere, storeThere, stored, unregistered } from "./transfers.test-helper.js"; +import { reportOf, runReported } from "./reports.test-helper.js"; + +// Transfers, with the world, the thread filling "transfersTest" and a thread filling none on this one, joined through +// MessageChannels: a run's Transferable input moves to the thread its system runs on. + +const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { + let done = false; + const stop = observe(value => { + if (done || !accept(value)) return; + done = true; + queueMicrotask(() => stop()); + resolve(value); + }); +}); + +const later = (milliseconds = 0): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)); + +/** Resolves once `remote` has a channel to another joined thread. */ +const peered = async (remote: World): Promise => { + while ((peersOf.get(remote)?.() ?? 0) === 0) await later(); +}; + +const sharedWith = (world: CreatedWorld, threads: readonly "transfersTest"[]): World => { + const channel = new MessageChannel(); + world.share(channel.port1); + return join(channel.port2, { threads }); +}; + +/** A world here, `filler` claiming "transfersTest", and `loader` claiming nothing, each with a channel to the other. */ +const joined = async (): Promise<{ readonly world: CreatedWorld; readonly filler: World; readonly loader: World }> => { + stored.length = marks.length = kept.length = 0; + const world = createWorld({ [workers]: 0 }); + const loader = sharedWith(world, []); + const filler = sharedWith(world, ["transfersTest"]); + await until(world.threads, threads => threads.length === 2 && threads.every(thread => thread.status === "joined")); + await peered(loader); + await peered(filler); + return { world, filler, loader }; +}; + +const bufferOf = (first: number, bytes = 1024): ArrayBuffer => { + const buffer = new ArrayBuffer(bytes); + new Uint8Array(buffer)[0] = first; + return buffer; +}; + +/** Resolves once a port's other end closed, or with false after `milliseconds`. */ +const closed = (port: MessagePort, milliseconds = 1000): Promise => new Promise(resolve => { + port.addEventListener("close", () => resolve(true)); + port.start(); + setTimeout(() => resolve(false), milliseconds); +}); + +const clean = { failures: [], skipped: [], dropped: 0, warnings: [] }; + +describe("transfers", () => { + it("move a run's Transferable input straight to the joined thread its system runs on, in call order with that thread's other runs", async () => { + const { world, filler, loader } = await joined(); + const buffers = [bufferOf(1), bufferOf(2)]; + const runs = [loader.run(storeThere, { buffer: buffers[0] }), loader.run(mark, { value: 1 }), loader.run(storeThere, { buffer: buffers[1] }), loader.run(mark, { value: 2 })]; + // Moved, not copied: the loader's are detached as soon as it runs them. + expect(buffers.map(buffer => buffer.byteLength)).toEqual([0, 0]); + const reports = await Promise.all((await Promise.all(runs)).map(run => reportOf(world, run))); + expect(reports.map(report => report.warnings.map(warning => warning.kind))).toEqual([["blocking"], [], ["blocking"], []]); + expect(stored.map(({ bytes, first, order }) => [bytes, first, order])).toEqual([[1024, 1, 0], [1024, 2, 2]]); + expect(marks).toEqual([{ value: 1, order: 1 }, { value: 2, order: 3 }]); + await loader.dispose(); + await filler.dispose(); + await world.dispose(); + }); + it("move a MessagePort, which can't be copied, to the joined thread its system runs on, where it works", async () => { + const { world, filler, loader } = await joined(); + const channel = new MessageChannel(); + const greeted = new Promise(resolve => { + channel.port2.onmessage = event => resolve(event.data); + }); + expect((await reportOf(world, await loader.run(greetThere, { port: channel.port1 }))).failures).toEqual([]); + expect(await greeted).toBe("anchor"); + channel.port2.close(); + await loader.dispose(); + await filler.dispose(); + await world.dispose(); + }); + it("move one to the world's thread where its system runs there", async () => { + const { world, filler, loader } = await joined(); + const buffer = bufferOf(3); + const ran = loader.run(storeHere, { buffer }); + expect(buffer.byteLength).toBe(0); + expect(await reportOf(world, await ran)).toEqual(clean); + expect(stored.map(({ bytes, first }) => [bytes, first])).toEqual([[1024, 3]]); + await loader.dispose(); + await filler.dispose(); + await world.dispose(); + }); + it("keep one on the joined thread its system runs on, when it runs it", async () => { + const { world, filler, loader } = await joined(); + const buffer = bufferOf(4); + await filler.run(storeThere, { buffer }); + expect(stored.map(({ bytes, first, value }) => [bytes, first, value === buffer])).toEqual([[1024, 4, true]]); + await loader.dispose(); + await filler.dispose(); + await world.dispose(); + }); + it("reject a run whose input moved to a thread that left, the thread's later runs going on", async () => { + const { world, filler, loader } = await joined(); + await filler.dispose(); + const moved = loader.run(storeThere, { buffer: bufferOf(5) }); + await expect(moved).rejects.toThrow("moved to a joined thread that's gone"); + // Once the world has told it, its runs go to the world's thread, which runs the system now. + await later(10); + await loader.run(storeThere, { buffer: bufferOf(6) }); + expect(stored.map(({ bytes, first }) => [bytes, first])).toEqual([[1024, 6]]); + await loader.dispose(); + await world.dispose(); + }); + it("release a value whose command drops, on its thread", async () => { + const { world, filler, loader } = await joined(); + const there = new MessageChannel(); + const here = new MessageChannel(); + const closes = [closed(there.port2), closed(here.port2)]; + const dead = asEntity(123456); + expect((await reportOf(world, await loader.run(attachThere, { entity: dead, value: there.port1 }))).dropped).toBe(1); + expect((await reportOf(world, await loader.run(attachHere, { entity: dead, value: here.port1 }))).dropped).toBe(1); + expect(await Promise.all(closes)).toEqual([true, true]); + await loader.dispose(); + await filler.dispose(); + await world.dispose(); + }); + it("release what moved for a run that never ran there", async () => { + const { world, filler, loader } = await joined(); + const channel = new MessageChannel(); + const closing = closed(channel.port2); + await expect(loader.run(unregistered, { value: channel.port1 })).rejects.toThrow("declares up"); + expect(await closing).toBe(true); + await loader.dispose(); + await filler.dispose(); + await world.dispose(); + }); + it("release a value whose command drops in a world no thread joined, and keep one whose command lands", async () => { + const world = createWorld({ [workers]: 0 }); + const dropped = new MessageChannel(); + const landed = new MessageChannel(); + const closes = [closed(dropped.port2, 200), closed(landed.port2, 200)]; + expect((await runReported(world, attachHere, { entity: asEntity(123456), value: dropped.port1 })).dropped).toBe(1); + expect((await runReported(world, spawnHere, { value: landed.port1 })).dropped).toBe(0); + expect(await Promise.all(closes)).toEqual([true, false]); + await world.dispose(); + }); +}); diff --git a/packages/ecs/src/implementation/world/transfers.ts b/packages/ecs/src/implementation/world/transfers.ts new file mode 100644 index 000000000..3b0c93c0f --- /dev/null +++ b/packages/ecs/src/implementation/world/transfers.ts @@ -0,0 +1,104 @@ +import type { TaskChannel } from './remote-systems.js'; + +// Transfers (threads.md, "Transfers"): a run's input holding Transferable values moves them to the thread its system +// runs on, a value whose command drops is released there, and a run's input held on that thread until its task. + +/** Global constructors of Transferable values, where the platform has them. */ +const constructors: readonly string[] = [ + "ArrayBuffer", "ImageBitmap", "OffscreenCanvas", "MessagePort", "VideoFrame", "AudioData", + "ReadableStream", "WritableStream", "TransformStream", "RTCDataChannel", +]; + +/** This platform's constructors of Transferable values, found once. */ +let found: readonly Function[] | null = null; +const platformConstructors = (): readonly Function[] => found ??= constructors.flatMap(name => { + const constructor: unknown = Reflect.get(globalThis, name); + return typeof constructor === "function" ? [constructor] : []; +}); + +/** Whether `value` is Transferable on this platform: a `SharedArrayBuffer` isn't, as it's shared rather than moved. */ +export const isTransferable = (value: unknown): value is object => { + if (typeof value !== "object" || value === null) return false; + for (const constructor of platformConstructors()) if (value instanceof constructor) return true; + return false; +}; + +const collect = (value: unknown, into: object[] | null): object[] | null => { + if (!isTransferable(value) || into?.includes(value) === true) return into; + (into ??= []).push(value); + return into; +}; + +/** + * The Transferable values a run's input holds, each once: its fields', and the elements of fields that are arrays; null + * for none, which allocates nothing. + */ +export const transferablesOf = (input: unknown): object[] | null => { + if (typeof input !== "object" || input === null) return null; + let into: object[] | null = null; + for (const field of Object.keys(input)) { + const value: unknown = Reflect.get(input, field); + if (Array.isArray(value)) { + for (const element of value) if (typeof element === "object") into = collect(element, into); + } + else into = collect(value, into); + } + return into; +}; + +const ignore = (): void => {}; + +/** Calls a method of `value` by name, if it has one, ignoring what it throws or rejects with. */ +const call = (value: object, method: string): void => { + const callable: unknown = Reflect.get(value, method); + if (typeof callable !== "function") return; + try { + const result: unknown = Reflect.apply(callable, value, []); + if (result instanceof Promise) result.catch(ignore); + } + catch { + // Released already, or locked: nothing more to release. + } +}; + +/** + * Releases a Transferable value no component will hold: closes an `ImageBitmap`, `VideoFrame`, `AudioData`, + * `MessagePort` or `RTCDataChannel`, cancels a `ReadableStream`, aborts a `WritableStream`, and both for a + * `TransformStream`'s sides. An `ArrayBuffer`'s and an `OffscreenCanvas`'s memory is the collector's. + */ +export const releaseValue = (value: unknown): void => { + if (!isTransferable(value)) return; + const name = value.constructor?.name; + if (name === "ReadableStream") call(value, "cancel"); + else if (name === "WritableStream") call(value, "abort"); + else if (name === "TransformStream") { + const readable: unknown = Reflect.get(value, "readable"); + const writable: unknown = Reflect.get(value, "writable"); + if (typeof readable === "object" && readable !== null) call(readable, "cancel"); + if (typeof writable === "object" && writable !== null) call(writable, "abort"); + } + else call(value, "close"); +}; + +/** Releases every Transferable value a run's input holds. */ +export const releaseInput = (input: unknown): void => { + for (const value of transferablesOf(input) ?? []) releaseValue(value); +}; + +/** + * On the world's thread, a run's input held on the joined thread its system runs on, which moved it there: the joined + * thread that ran it, by number, the run's sequence there, the channel of the thread holding it, and whether a task + * there took it, so what none took is released once the run finishes. + */ +export type HeldInput = { readonly from: number; readonly sequence: number; readonly channel: TaskChannel; taken: boolean }; + +const held = new WeakSet(); + +export const heldInput = (from: number, sequence: number, channel: TaskChannel): HeldInput => { + const input: HeldInput = { from, sequence, channel, taken: false }; + held.add(input); + return input; +}; + +/** Whether a run's input is held on a joined thread. */ +export const isHeld = (input: unknown): input is HeldInput => typeof input === "object" && input !== null && held.has(input); diff --git a/packages/ecs/src/implementation/world/up-tree.test.ts b/packages/ecs/src/implementation/world/up-tree.test.ts new file mode 100644 index 000000000..b20e66878 --- /dev/null +++ b/packages/ecs/src/implementation/world/up-tree.test.ts @@ -0,0 +1,152 @@ +import { expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; + +const local = Symbol("upTreeTest/local"); +const full = Symbol("upTreeTest/full"); +const filtered = Symbol("upTreeTest/filtered"); +const parent = Symbol("upTreeTest/parent"); +const hidden = Symbol("upTreeTest/hidden"); + +declare module "@adobe/ecs" { + interface Components { + [local]: { type: number }; + [full]: { type: number }; + [filtered]: { type: number }; + [parent]: { type: Entity; reference: "weak" }; + [hidden]: { type: true }; + } +} + +Component.register(local, { type: { type: "number" } }); +Component.register(full, { type: { type: "number" } }); +Component.register(filtered, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "weak" }); +Component.register(hidden, { type: { const: true } }); + +const propagateFull = System.create({ + name: "upTreeTest/propagateFull", + kernel: "cpu", + components: { read: [local], write: [full], not: [hidden] }, + up: { through: parent, read: [full] }, +}, (row, { up }) => { + row[full] = (up === null ? 0 : up[full]) + row[local]; +}); +const propagateFiltered = System.create({ + name: "upTreeTest/propagateFiltered", + kernel: "cpu", + components: { read: [local], write: [filtered], changed: [local], not: [hidden] }, + up: { through: parent, read: [filtered] }, +}, (row, { up }) => { + row[filtered] = (up === null ? 0 : up[filtered]) + row[local]; +}); + +type Operation = + | { readonly kind: "spawn"; readonly local: number; readonly parent: Entity | null } + | { readonly kind: "despawn" | "hide" | "show" | "orphan"; readonly entity: Entity } + | { readonly kind: "reparent"; readonly entity: Entity; readonly to: Entity } + | { readonly kind: "set"; readonly entity: Entity; readonly local: number }; +const operations: Operation[] = []; +const apply = System.create({ name: "upTreeTest/apply" }, ({ commands }) => { + for (const operation of operations.splice(0)) { + switch (operation.kind) { + case "spawn": + if (operation.parent === null) commands.spawn([local, full, filtered])({ [local]: operation.local, [full]: 0, [filtered]: 0 }); + else commands.spawn([local, full, filtered, parent])({ [local]: operation.local, [full]: 0, [filtered]: 0, [parent]: operation.parent }); + break; + case "despawn": commands.despawn(operation.entity); break; + case "hide": commands.add(operation.entity, hidden, true); break; + case "show": commands.remove(operation.entity, hidden); break; + case "orphan": commands.remove(operation.entity, parent); break; + case "reparent": commands.add(operation.entity, parent, operation.to); break; + case "set": commands.add(operation.entity, local, operation.local); break; + } + } +}); + +/** A small deterministic generator, so a failure replays. */ +const generator = (seed: number) => { + let state = seed; + return (limit: number): number => { + state = (state * 1103515245 + 12345) % 2 ** 31; + // The high bits: a power-of-two modulus leaves the low ones short cycles. + return Math.floor((state / 2 ** 31) * limit); + }; +}; + +/** What each matched row outside a cycle should hold, from the world's current tree and the values it stores. */ +const expected = async (ecs: World, key: typeof full | typeof filtered): Promise> => { + const entities = await select(ecs, { required: [local] }); + const alive = new Set(entities); + const isHidden = new Set(await select(ecs, { required: [hidden] })); + // The walk reads only live entities, so one read of each component up front serves it. + const byEntity = (values: readonly T[]): Map => new Map(entities.map((entity, i) => [entity, values[i]])); + const parents = byEntity(await readEach(ecs, entities, parent)); + const locals = byEntity(await readEach(ecs, entities, local)); + const keys = byEntity(await readEach(ecs, entities, key)); + const parentOf = (entity: Entity): Entity | null => { + const value = parents.get(entity); + return value !== undefined && value !== null && alive.has(value) ? value : null; + }; + const matched = (entity: Entity): boolean => alive.has(entity) && !isHidden.has(entity); + const isCyclic = (entity: Entity): boolean => { + const seen = new Set(); + for (let current: Entity | null = entity; current !== null && matched(current); current = parentOf(current)) { + if (seen.has(current)) return true; + seen.add(current); + } + return false; + }; + const values = new Map(); + const valueOf = (entity: Entity): number => { + if (!matched(entity) || isCyclic(entity)) return keys.get(entity) ?? 0; + const known = values.get(entity); + if (known !== undefined) return known; + const up = parentOf(entity); + const value = (up === null ? 0 : valueOf(up)) + (locals.get(entity) ?? 0); + values.set(entity, value); + return value; + }; + const result = new Map(); + for (const entity of entities) if (matched(entity) && !isCyclic(entity)) result.set(entity, valueOf(entity)); + return result; +}; + +const actual = async (ecs: World, key: typeof full | typeof filtered, rows: Iterable): Promise> => { + const entities = [...rows]; + const values = await readEach(ecs, entities, key); + return new Map(entities.map((entity, i) => [entity, values[i] ?? 0])); +}; + +it.each([true, false])("keeps its tree current across spawns, despawns, reparents, cycles and matching changes, with WASM %s", async wasm => { + await using ecs = createWorld({ [workers]: 0 }, { wasm }); + const random = generator(7); + for (let k = 0; k < 30; k++) operations.push({ kind: "spawn", local: random(10), parent: null }); + await ecs.run(apply); + for (let frame = 0; frame < 300; frame++) { + const entities = await select(ecs, { required: [local] }); + const any = (): Entity => entities[random(entities.length)]; + for (let k = 0; k < 4; k++) { + switch (random(8)) { + case 0: operations.push({ kind: "spawn", local: random(10), parent: random(4) === 0 ? null : any() }); break; + case 1: if (entities.length > 20) operations.push({ kind: "despawn", entity: any() }); break; + case 2: operations.push({ kind: random(2) === 0 ? "hide" : "show", entity: any() }); break; + case 3: operations.push({ kind: "orphan", entity: any() }); break; + case 4: case 5: operations.push({ kind: "reparent", entity: any(), to: any() }); break; + default: operations.push({ kind: "set", entity: any(), local: random(10) }); + } + } + await ecs.run(apply); + await ecs.run(propagateFull); + await ecs.run(propagateFiltered); + for (const key of [full, filtered] as const) { + const want = await expected(ecs, key); + expect(await actual(ecs, key, want.keys()), `${String(key)}, frame ${frame}`).toEqual(want); + } + } +}); diff --git a/packages/ecs/src/implementation/world/up-workers.cases.ts b/packages/ecs/src/implementation/world/up-workers.cases.ts new file mode 100644 index 000000000..4bdf26dbe --- /dev/null +++ b/packages/ecs/src/implementation/world/up-workers.cases.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./up-workers.fixture.js"; + +/** `up` runs depth by depth across workers: the cases, over the fixture built for node or from source. */ +export const upWorkersCases = (load: () => Promise) => describe("up", () => { + it("runs depth by depth across workers, as on the main thread alone", async () => { + const fixture = await load(); + const alone = await fixture.simulate(0, false); + const parallel = await fixture.simulate(2, true); + expect(parallel.values).toEqual(alone.values); + expect(parallel.windows).toBeGreaterThan(0); + }); +}); diff --git a/packages/ecs/src/implementation/world/up-workers.fixture.ts b/packages/ecs/src/implementation/world/up-workers.fixture.ts new file mode 100644 index 000000000..18fb480bc --- /dev/null +++ b/packages/ecs/src/implementation/world/up-workers.fixture.ts @@ -0,0 +1,67 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { countingWorkers } from "./platforms.test-helper.js"; + +/** Scenarios of `up` across workers, run built in node and from source in chromium. */ + +const local = Symbol("upWorkersTest/local"); +const world = Symbol("upWorkersTest/world"); +const parent = Symbol("upWorkersTest/parent"); + +declare module "@adobe/ecs" { + interface Components { + [local]: { type: number }; + [world]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + } +} + +Component.register(local, { type: { type: "number" } }); +Component.register(world, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); + +const propagate = System.create({ + name: "upWorkersTest/propagate", + kernel: "cpu", + components: { read: [local], write: [world], changed: [local] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + row[world] = (up === null ? 0 : up[world]) + row[local] * 2; +}); +/** 100 trees, each a root with 40 levels of two children, ordered so storage order isn't depth order. */ +const grow = System.create({ name: "upWorkersTest/grow" }, ({ commands }) => { + for (let tree = 0; tree < 100; tree++) { + let level = [commands.spawn([local, world])({ [local]: tree, [world]: 0 })]; + for (let depth = 1; depth < 40; depth++) { + const at = level[depth % level.length]; + level = [commands.spawn([local, world, parent])({ [local]: depth, [world]: 0, [parent]: at }), commands.spawn([local, world, parent])({ [local]: -depth, [world]: 0, [parent]: at })]; + } + } +}); +const nudge = System.create({ name: "upWorkersTest/nudge", components: { write: [local] } }, row => { + if (row[local] === 7) row[local] = 70; +}); + +/** Values after `propagate` runs around a nudge, sorted, and the windows opened on workers meanwhile. */ +export const simulate = async (count: number, wasm: boolean): Promise<{ readonly values: readonly (number | null)[]; readonly windows: number }> => { + const counting = countingWorkers(); + const ecs = createWorld({ [workers]: count }, { minimumChunk: 64, wasm, pool: counting.platform }); + try { + await ecs.run(grow); + const before = counting.posted("window"); + await ecs.run(propagate); + await ecs.run(nudge); + await ecs.run(propagate); + await ecs.run(propagate); + const windows = counting.posted("window") - before; + const values = (await readEach(ecs, await select(ecs, { required: [world] }), world)).sort((a, b) => (a ?? 0) - (b ?? 0)); + return { values, windows }; + } + finally { + await ecs.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/up.browser.test.ts b/packages/ecs/src/implementation/world/up.browser.test.ts new file mode 100644 index 000000000..8757ebab8 --- /dev/null +++ b/packages/ecs/src/implementation/world/up.browser.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; + +const parent = Symbol("upBrowserTest/parent"); +const offset = Symbol("upBrowserTest/offset"); +const placed = Symbol("upBrowserTest/placed"); +const marked = Symbol("upBrowserTest/marked"); + +declare module "@adobe/ecs" { + interface Components { + [parent]: { type: Entity; reference: "owner" }; + [offset]: { type: number }; + [placed]: { type: number }; + [marked]: { type: true }; + } +} + +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(offset, { type: { type: "number", precision: 1 } }); +Component.register(placed, { type: { type: "number", precision: 1 } }); +Component.register(marked, { type: { const: true } }); +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; + +const place = System.create({ + name: "upBrowserTest/place", + kernel: "gpu", + components: { read: [offset], write: [placed] }, + up: { through: parent, read: [placed] }, +}, (row, { up }) => { + row[placed] = up === null ? row[offset] : up[placed] + row[offset]; +}); +/** Trees whose children are in two archetypes, so a depth's rows have parents in several. */ +const plant = System.create({ name: "upBrowserTest/plant" }, ({ commands }) => { + for (let tree = 0; tree < 50; tree++) { + let level = [commands.spawn([offset, placed])({ [offset]: tree, [placed]: 0 })]; + for (let depth = 1; depth < 12; depth++) { + const next = []; + for (const at of level.slice(0, 2)) { + next.push(commands.spawn([offset, placed, parent])({ [offset]: depth, [placed]: 0, [parent]: at })); + next.push(commands.spawn([offset, placed, parent, marked])({ [offset]: -1, [placed]: 0, [parent]: at, [marked]: true })); + } + level = next; + } + } +}); + +const disposals: World[] = []; +afterEach(async () => { + await Promise.all(disposals.splice(0).map(item => item.dispose())); +}); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +const placements = async (gpu: GPUDevice | null): Promise => { + const ecs = createWorld({ [workers]: 0, [gpuDevice]: gpu }); + disposals.push(ecs); + await ecs.run(plant); + await ecs.run(place); + await ecs.run(place); + return (await readEach(ecs, await select(ecs, { required: [placed] }), placed)).sort((a, b) => (a ?? 0) - (b ?? 0)); +}; + +describe("up on the GPU", () => { + it("runs depth by depth, a dispatch per group, as on the CPU", async () => { + const cpu = await placements(null); + expect(await placements(await device())).toEqual(cpu); + }); +}); diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts new file mode 100644 index 000000000..e2ed5971f --- /dev/null +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { System } from "../../interface/system/system.js"; +import { definitionOf } from "../../interface/registry/index.js"; +import { kernelOf } from "./attach-kernel.js"; +import { createWorld } from "./create.js"; +import { read, readEach } from "./reads.test-helper.js"; +import { bindKernel } from "./kernels.js"; +import { createState } from "./state.js"; +import { workers } from "../../interface/resources/workers.js"; + +const local = Symbol("upTest/local"); +const world = Symbol("upTest/world"); +const parent = Symbol("upTest/parent"); +const label = Symbol("upTest/label"); +const visible = Symbol("upTest/visible"); +const shown = Symbol("upTest/shown"); +const anchor = Symbol("upTest/anchor"); + +declare module "@adobe/ecs" { + interface Components { + [local]: { type: number }; + [world]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [label]: { type: string }; + [visible]: { type: boolean }; + [shown]: { type: boolean }; + [anchor]: { type: true }; + } +} + +Component.register(local, { type: { type: "number" } }); +Component.register(world, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(label, { type: { type: "string" } }); +Component.register(visible, { type: { type: "boolean" } }); +Component.register(shown, { type: { type: "boolean" } }); +Component.register(anchor, { type: { const: true } }); + +const ran: string[] = []; +const propagate = System.create({ + name: "upTest/propagate", + components: { read: [local, label], write: [world] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + ran.push(row[label]); + row[world] = (up === null ? 0 : up[world]) + row[local]; +}); +const propagateChanged = System.create({ + name: "upTest/propagateChanged", + components: { read: [local, label], write: [world], changed: [local] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + ran.push(row[label]); + row[world] = (up === null ? 0 : up[world]) + row[local]; +}); +const propagateAdded = System.create({ + name: "upTest/propagateAdded", + components: { read: [local, label], write: [world], added: [local] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + ran.push(row[label]); + row[world] = (up === null ? 0 : up[world]) + row[local]; +}); +const propagateChangedKernel = System.create({ + name: "upTest/propagateChangedKernel", + kernel: "cpu", + components: { read: [local], write: [world], changed: [local] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + row[world] = (up === null ? 0 : up[world]) + row[local]; +}); +const propagateKernel = System.create({ + name: "upTest/propagateKernel", + kernel: "cpu", + components: { read: [local], write: [world] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + row[world] = (up === null ? 0 : up[world]) + row[local]; +}); + +/** Spawns a root with two trees under it, the grandchild stored before its parent so storage order isn't depth order. */ +const entities: { [name: string]: Entity } = {}; +const spawn = System.create({ name: "upTest/spawn" }, ({ commands }) => { + entities.root = commands.spawn([label, local, world])({ [label]: "root", [local]: 1, [world]: 0 }); + entities.grandchild = commands.spawn([label, local, world, parent])({ [label]: "grandchild", [local]: 100, [world]: 0, [parent]: entities.root }); + entities.child = commands.spawn([label, local, world, parent])({ [label]: "child", [local]: 10, [world]: 0, [parent]: entities.root }); + entities.other = commands.spawn([label, local, world])({ [label]: "other", [local]: 1000, [world]: 0 }); +}); +const nest = System.create({ name: "upTest/nest" }, ({ commands }) => { commands.add(entities.grandchild, parent, entities.child); }); +const bump = System.create({ name: "upTest/bump", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { + commands.add(input.entity, local, 2); +}); +const loop = System.create({ name: "upTest/loop" }, ({ commands }) => { + commands.add(entities.root, parent, entities.grandchild); + commands.spawn([label, local, world, parent])({ [label]: "under", [local]: 5, [world]: 0, [parent]: entities.child }); +}); +/** Puts the root under the grandchild, two levels below it, then under `other`. */ +const close = System.create({ name: "upTest/close" }, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); }); +const open = System.create({ name: "upTest/open" }, ({ commands }) => { commands.add(entities.root, parent, entities.other); }); +const orphan = System.create({ name: "upTest/orphan" }, ({ commands }) => { commands.remove(entities.grandchild, parent); }); + +const worlds = async (value: (entity: Entity) => Promise): Promise<{ [name: string]: number | null }> => + Object.fromEntries(await Promise.all(Object.entries(entities).map(async ([name, entity]) => [name, await value(entity)]))); + +const show = System.create({ + name: "upTest/show", + kernel: "cpu", + components: { read: [visible], write: [shown] }, + up: { through: parent, read: [shown] }, +}, (row, { up }) => { + row[shown] = up === null ? row[visible] : up[shown] && row[visible]; +}); + +/** Children of one archetype whose parents are in three: a matched one, one the query doesn't match, and one without `world`. */ +const mixed: { [name: string]: Entity } = {}; +const spawnMixed = System.create({ name: "upTest/spawnMixed" }, ({ commands }) => { + mixed.root = commands.spawn([local, world])({ [local]: 1, [world]: 0 }); + mixed.anchored = commands.spawn([local, world, anchor])({ [local]: 2, [world]: 0, [anchor]: true }); + mixed.unmatched = commands.spawn([world])({ [world]: 50 }); + mixed.bare = commands.spawn([label])({ [label]: "bare" }); + mixed.underRoot = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.root }); + mixed.underUnmatched = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.unmatched }); + mixed.underAnchored = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.anchored }); + mixed.underBare = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.bare }); +}); +const growParents = System.create({ name: "upTest/growParents" }, ({ commands }) => { + for (let i = 0; i < 100; i++) commands.spawn([local, world, anchor])({ [local]: 0, [world]: 0, [anchor]: true }); +}); +const shift = System.create({ name: "upTest/shift", components: { write: [local], has: [anchor] } }, row => { row[local] += 1; }); + +describe("up", () => { + for (const [kind, system] of [["main-thread", propagate], ["kernel", propagateKernel]] as const) { + it(`runs parents before children, so each reads its parent's value from the same run, for a ${kind} system`, async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(system); + expect(await worlds(entity => read(ecs, entity, world))).toEqual({ root: 1, grandchild: 111, child: 11, other: 1000 }); + }); + } + + it("passes a row whose parent changed since the system last ran, with change filters", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateChanged); + ran.length = 0; + await ecs.run(bump, { entity: entities.root }); + await ecs.run(propagateChanged); + expect(ran).toEqual(["root", "child", "grandchild"]); + expect(await worlds(entity => read(ecs, entity, world))).toEqual({ root: 2, grandchild: 112, child: 12, other: 1000 }); + }); + + it("doesn't pass a row whose component an added filter names only changed", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateAdded); + ran.length = 0; + await ecs.run(bump, { entity: entities.root }); + await ecs.run(propagateAdded); + expect(ran).toEqual([]); + }); + + it("passes a row whose parent a kernel wrote earlier in the run, with change filters", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateChangedKernel); + await ecs.run(bump, { entity: entities.root }); + await ecs.run(propagateChangedKernel); + expect(await worlds(entity => read(ecs, entity, world))).toEqual({ root: 2, grandchild: 112, child: 12, other: 1000 }); + }); + + it("passes a row whose parent reference changed, with change filters", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(propagateChanged); + ran.length = 0; + await ecs.run(nest); + await ecs.run(propagateChanged); + expect(ran).toEqual(["grandchild"]); + expect(await read(ecs, entities.grandchild, world)).toBe(111); + }); + + it("passes a row whose parent reference was removed, with change filters", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateChanged); + ran.length = 0; + await ecs.run(orphan); + await ecs.run(propagateChanged); + expect(ran).toEqual(["grandchild"]); + expect(await read(ecs, entities.grandchild, world)).toBe(100); + }); + + it("finds a cycle a reparent closes after the tree was built, and runs its rows again once it opens", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateKernel); + await ecs.run(close); + await ecs.run(propagateKernel); + expect(await worlds(entity => read(ecs, entity, world))).toEqual({ root: 1, grandchild: 111, child: 11, other: 1000 }); + await ecs.run(open); + await ecs.run(propagateKernel); + expect(await worlds(entity => read(ecs, entity, world))).toEqual({ root: 1001, grandchild: 1111, child: 1011, other: 1000 }); + }); + + it("doesn't run rows on or under a parent cycle", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(loop); + ran.length = 0; + await ecs.run(propagate); + expect(ran).toEqual(["other"]); + }); + + it("reads parents in several archetypes, including ones the query doesn't match, and null for one without what up reads", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawnMixed); + await ecs.run(propagateKernel); + const values = Object.fromEntries(await Promise.all(Object.entries(mixed).map(async ([name, entity]) => [name, await read(ecs, entity, world)]))); + expect(values).toEqual({ root: 1, anchored: 2, unmatched: 50, bare: null, underRoot: 11, underUnmatched: 60, underAnchored: 12, underBare: 10 }); + }); + + it("reads a parent's archetype again after it grows", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawnMixed); + await ecs.run(propagateKernel); + await ecs.run(growParents); + await ecs.run(shift); + await ecs.run(propagateKernel); + expect(await read(ecs, mixed.underAnchored, world)).toBe(13); + }); + + it("reads a parent the query doesn't match again after it moves to another archetype", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawnMixed); + await ecs.run(propagateKernel); + await ecs.run(System.create({ name: "upTest/moveUnmatched" }, ({ commands }) => { commands.add(mixed.unmatched, [anchor, world], { [anchor]: true, [world]: 80 }); })); + await ecs.run(propagateKernel); + expect(await read(ecs, mixed.underUnmatched, world)).toBe(90); + }); + + it("reads booleans through up", async () => { + await using ecs = createWorld({ [workers]: 0 }); + const tree: Entity[] = []; + await ecs.run(System.create({ name: "upTest/spawnShown" }, ({ commands }) => { + const top = commands.spawn([visible, shown])({ [visible]: false, [shown]: true }); + tree.push(top, commands.spawn([visible, shown, parent])({ [visible]: true, [shown]: true, [parent]: top })); + const other = commands.spawn([visible, shown])({ [visible]: true, [shown]: false }); + tree.push(other, commands.spawn([visible, shown, parent])({ [visible]: true, [shown]: false, [parent]: other })); + })); + await ecs.run(show); + expect(await readEach(ecs, tree, shown)).toEqual([false, false, true, true]); + }); + + it("runs as WASM, as JS does", async () => { + const definition = definitionOf(propagateKernel); + const description = kernelOf(propagateKernel); + if (definition === undefined || definition.kind === "composite" || description === undefined) throw new Error("not a compiled leaf"); + expect(bindKernel(createState(), definition, description).wasm).not.toBeNull(); + const results = await Promise.all([true, false].map(async wasm => { + await using ecs = createWorld({ [workers]: 0 }, { wasm }); + await ecs.run(spawnMixed); + await ecs.run(propagateKernel); + await ecs.run(growParents); + await ecs.run(shift); + await ecs.run(propagateKernel); + return await readEach(ecs, Object.values(mixed), world); + })); + expect(results[0]).toEqual(results[1]); + }); + + it("rejects a through that isn't a reference, for untyped declarations", async () => { + // @ts-expect-error through is a reference component + expect(() => System.create({ name: "upTest/untyped", components: { write: [world] }, up: { through: local, read: [world] } }, row => { row[world] = 0; })).toThrow('System "upTest/untyped": up.through "upTest/local" isn\'t a reference component.'); + }); +}); diff --git a/packages/ecs/src/implementation/world/up.ts b/packages/ecs/src/implementation/world/up.ts new file mode 100644 index 000000000..f86c00051 --- /dev/null +++ b/packages/ecs/src/implementation/world/up.ts @@ -0,0 +1,684 @@ +import type { Up } from '../../interface/system/access.js'; +import type { Archetype, Storage } from './archetype.js'; +import { generationOf, indexOf } from './entities.js'; +import { journalEnd } from './journal.js'; +import { chunkShift } from './kernel.js'; +import { idOf, type Key, type State } from './state.js'; +import { allocateArray, freeArray } from './arrays.js'; +import type { Memory } from './memory.js'; +import { createOrder, type Order } from './order.js'; + +/** Rows `rows[start]` to `rows[end - 1]` of `matches[owner]`, whose parents are rows `parents[n]` of the archetype with id `parent`, or -1. */ +export type Run = { readonly owner: number; readonly parent: number; readonly rows: Int32Array; readonly parents: Int32Array; readonly start: number; readonly end: number }; + +/** A filtered run's rows, a depth at a time: each call lists the next depth's rows that run, or null after the last. */ +export type Pass = { readonly next: () => readonly Run[] | null; readonly clear: () => void }; + +/** `levelOf` values: not matched, and on or under a parent cycle, which never runs. Matched rows hold depth + 1. */ +const unmatched = 0; +const cyclic = -1; +/** A depth with more marked rows than this share of the matched rows is listed by scanning its groups in order. */ +const denseShare = 1 / 8; +/** Rows below this many are sorted rather than scanned. */ +const denseMinimum = 4096; +/** Sort keys pack a row with its parent's archetype: `(parent + 1) * rowLimit + row`. */ +const rowLimit = 2 ** 27; + +/** Grows an array in the arena to at least `length`, without keeping its contents, so WASM can address it. */ +const fitArena = (memory: Memory, array: Int32Array, length: number): Int32Array => { + if (array.length >= length) return array; + if (array.length > 0) freeArray(memory, array); + return allocateArray(memory, Int32Array, Math.max(length, 2 * array.length)); +}; + +/** Grows an array to at least `length`, without keeping its contents. */ +const fit = (array: Int32Array, length: number): Int32Array => array.length >= length ? array : new Int32Array(Math.max(length, 2 * array.length)); + +/** Grows an array to at least `length`, keeping its contents and filling the rest with `value`. */ +const extend = (array: Int32Array, length: number, value: number): Int32Array => { + if (array.length >= length) return array; + const grown = new Int32Array(Math.max(length, 2 * array.length)); + grown.set(array); + grown.fill(value, array.length); + return grown; +}; + +/** + * Orders an `up` system's rows by depth through its `through` reference. A row's depth is one more than its parent's + * when its parent is among the matched rows, and 0 otherwise, so rows of one depth never read each other's writes. + * + * The tree is kept by entity index, current with the journal of what sync points changed: each matched entity's + * parent, its level, and the matched entities holding it as their parent, as linked lists. A full run reads an order + * of every row, patched for the entities whose row, level or parent changed. A filtered run marks the rows that may + * pass, by depth, and lists only those. + */ +export const createUp = (state: State, up: Up, isFiltered: boolean) => { + const through = idOf(state, up.through); + const readKeys: readonly Key[] = up.read ?? []; + const read = readKeys.map(key => idOf(state, key)); + state.journal.readers++; + + /** By entity index: the parent's entity index or -1, the level, and the first child, next and previous sibling as index + 1, or 0. */ + let parentOf = new Int32Array(0); + let levelOf = new Int32Array(0); + let firstChild = new Int32Array(0); + let nextChild = new Int32Array(0); + let previousChild = new Int32Array(0); + /** By entity index: the pass that last saw it, for settling levels and reading the journal once per index. */ + let stamps = new Int32Array(0); + let pass = 0; + /** The journal's position read up to, or -1 before the tree is first built. */ + let position = -1; + /** By archetype id: its index in `matches`, or -1. */ + let ownerOf = new Int32Array(0); + let ownersOf: readonly Archetype[] | null = null; + let ownersFor = -1; + let ownersAmong = -1; + /** By archetype id: 1 when it has every component `read` lists, 2 when it doesn't, 0 until checked. */ + let readable = new Int8Array(0); + /** Entities whose parent changed since the last filtered run, which then pass. */ + let touched: number[] = []; + /** Whether the journal lost entries since the last filtered run, which then runs every row. */ + let rebuilt = false; + /** By entity index: its archetype id when the tree last saw it, or -1. */ + let archetypeAt = new Int32Array(0); + /** Entities whose slot in the order may have changed since it was last patched, once each. */ + let moved: number[] = []; + let movedStamps = new Int32Array(0); + let movedPass = 1; + /** Whether the order must be built again from every row. */ + let unordered = true; + const order = createOrder(state.memory); + + /** Whether an archetype holds every component `read` lists, so a parent in it has `up` values. */ + const isReadable = (id: number): boolean => { + if (readable.length <= id) { + const grown = new Int8Array(Math.max(id + 1, 2 * readable.length)); + grown.set(readable); + readable = grown; + } + if (readable[id] === 0) { + const archetype = state.archetypes[id]; + readable[id] = archetype !== undefined && !archetype.resource && read.every(component => archetype.storageById[component] !== undefined) ? 1 : 2; + } + return readable[id] === 1; + }; + + const refreshOwners = (matches: readonly Archetype[]): void => { + if (ownersOf === matches && ownersFor === matches.length && ownersAmong === state.archetypes.length) return; + ownerOf = fit(ownerOf, state.archetypes.length); + ownerOf.fill(-1); + matches.forEach((archetype, owner) => { ownerOf[archetype.id] = owner; }); + ownersOf = matches; + ownersFor = matches.length; + ownersAmong = state.archetypes.length; + }; + + /** The entity index of an entity's live parent, or -1. */ + const parentAt = (index: number): number => { + const { entities } = state; + const archetype = state.archetypes[entities.archetype[index]]; + const storage: Storage | undefined = archetype.storageById[through]; + const layout = storage?.layout; + // An entity is one f64, read straight from its column. + const leaf = layout?.kind === "linear" ? layout.leaves[0] : undefined; + if (storage === undefined || leaf === undefined) return -1; + const parent = storage.views[leaf.view][entities.row[index] * leaf.unitStride + leaf.unitOffset]; + const target = parent >>> 0; + if (parent <= 0 || target >= entities.length || entities.archetype[target] < 0 || entities.generation[target] !== generationOf(parent)) return -1; + return target; + }; + + const isMatched = (index: number): boolean => { + const archetype = state.entities.archetype[index]; + return archetype >= 0 && ownerOf[archetype] >= 0; + }; + + const unlink = (index: number): void => { + const parent = parentOf[index]; + if (parent < 0) return; + const previous = previousChild[index]; + const next = nextChild[index]; + if (previous !== 0) nextChild[previous - 1] = next; + else firstChild[parent] = next; + if (next !== 0) previousChild[next - 1] = previous; + }; + + const link = (index: number, parent: number): void => { + parentOf[index] = parent; + if (parent < 0) return; + const first = firstChild[parent]; + nextChild[index] = first; + previousChild[index] = 0; + if (first !== 0) previousChild[first - 1] = index + 1; + firstChild[parent] = index + 1; + }; + + /** Grows the arrays by entity index to the entity table. */ + const grow = (): void => { + const length = state.entities.length; + if (parentOf.length >= length) return; + parentOf = extend(parentOf, length, -1); + levelOf = extend(levelOf, length, unmatched); + firstChild = extend(firstChild, length, 0); + nextChild = extend(nextChild, length, 0); + previousChild = extend(previousChild, length, 0); + stamps = extend(stamps, length, 0); + movedStamps = extend(movedStamps, length, 0); + archetypeAt = extend(archetypeAt, length, -1); + }; + + /** Notes that an entity's slot in the order may have changed; past a quarter of the rows, the order is built again instead. */ + const move = (index: number): void => { + if (unordered || movedStamps[index] === movedPass) return; + movedStamps[index] = movedPass; + moved.push(index); + if (moved.length > Math.max(1024, order.live() / 4)) { + unordered = true; + moved = []; + movedPass++; + } + }; + + let stack = new Int32Array(0); + let queue = new Int32Array(0); + + /** + * Sets the levels of the matched entities among `pending`, and of each descendant whose level changes as a result. + * Each walks up to a root, or to an entity this call already settled, so every level it sets is exact, and a cycle + * is found where a walk meets itself; each entity is walked once per call. + */ + const settle = (pending: readonly number[] | Int32Array, count: number): void => { + const waiting = ++pass; + const walkedPending = ++pass; + const walked = ++pass; + const settled = ++pass; + for (let k = 0; k < count; k++) if (levelOf[pending[k]] !== unmatched) stamps[pending[k]] = waiting; + let queued = 0; + const descend = (index: number): void => { + const level = levelOf[index]; + const want = level === cyclic ? cyclic : level + 1; + for (let child = firstChild[index]; child !== 0; child = nextChild[child - 1]) { + const c = child - 1; + if (levelOf[c] === want) { + stamps[c] = settled; + continue; + } + levelOf[c] = want; + stamps[c] = settled; + move(c); + queue = extend(queue, queued + 1, 0); + queue[queued++] = c; + } + }; + for (let k = 0; k < count; k++) { + const start = pending[k]; + if (stamps[start] !== waiting) continue; + let height = 0; + let current = start; + for (; current >= 0 && levelOf[current] !== unmatched; current = parentOf[current]) { + const stamp = stamps[current]; + if (stamp === settled || stamp === walkedPending || stamp === walked) break; + stamps[current] = stamp === waiting ? walkedPending : walked; + stack = extend(stack, height + 1, 0); + stack[height++] = current; + } + const stop = current < 0 || levelOf[current] === unmatched ? -1 : stamps[current]; + let level = stop === -1 ? 0 : stop === settled ? levelOf[current] : cyclic; + // The pending were journaled, so their children may have changed; the rest only if their level did. + stack = extend(stack, 2 * height, 0); + let descending = 0; + for (let h = height - 1; h >= 0; h--) { + const node = stack[h]; + level = level === cyclic ? cyclic : level + 1; + const changed = levelOf[node] !== level; + if (changed) move(node); + if (changed || stamps[node] === walkedPending) stack[descending++ + height] = node; + levelOf[node] = level; + stamps[node] = settled; + } + for (let d = 0; d < descending; d++) descend(stack[height + d]); + while (queued > 0) descend(queue[--queued]); + } + }; + + /** Builds the tree from every matched row. */ + const build = (matches: readonly Archetype[]): void => { + const length = state.entities.length; + parentOf.fill(-1, 0, length); + levelOf.fill(unmatched, 0, length); + firstChild.fill(0, 0, length); + archetypeAt.set(state.entities.archetype.subarray(0, length)); + let count = 0; + for (const archetype of matches) count += archetype.count; + const all = new Int32Array(count); + let at = 0; + for (const archetype of matches) { + for (let row = 0; row < archetype.count; row++) { + const index = archetype.entities[row] >>> 0; + levelOf[index] = 1; + all[at++] = index; + } + } + for (let k = 0; k < count; k++) link(all[k], parentAt(all[k])); + settle(all, count); + }; + + /** Brings the tree up to date with the journal. */ + const update = (matches: readonly Archetype[]): void => { + refreshOwners(matches); + grow(); + const { journal } = state; + const end = journalEnd(journal); + if (position === end) return; + if (position < journal.first) { + build(matches); + position = end; + rebuilt = isFiltered; + touched = []; + unordered = true; + return; + } + const seen = ++pass; + const pending: number[] = []; + for (let at = position; at < end; at++) { + const index = journal.indexes[at - journal.first]; + if (stamps[index] === seen) continue; + stamps[index] = seen; + // Its row may have changed, and so its children's parent row: the order moves them. + move(index); + for (let child = firstChild[index]; child !== 0; child = nextChild[child - 1]) move(child - 1); + const archetype = state.entities.archetype[index]; + const otherArchetype = archetype !== archetypeAt[index]; + archetypeAt[index] = archetype; + if (!isMatched(index)) { + if (levelOf[index] === unmatched) continue; + unlink(index); + parentOf[index] = -1; + levelOf[index] = unmatched; + // Its children's parent no longer runs: they're roots now. + for (let child = firstChild[index]; child !== 0; child = nextChild[child - 1]) { + pending.push(child - 1); + if (isFiltered) touched.push(child - 1); + } + continue; + } + const parent = parentAt(index); + const fresh = levelOf[index] === unmatched; + if (fresh) { + levelOf[index] = 1; + link(index, parent); + } + else if (parent !== parentOf[index]) { + unlink(index); + link(index, parent); + } + else if (!otherArchetype) continue; + pending.push(index); + if (!isFiltered) continue; + touched.push(index); + // What its children read of it may differ in another archetype. A child whose reference changed is + // journaled itself, as is one whose parent is gone, since `through` has a reference policy. + if (otherArchetype) for (let child = firstChild[index]; child !== 0; child = nextChild[child - 1]) touched.push(child - 1); + } + position = end; + settle(pending, pending.length); + }; + + /** By index among the matched rows: its entity index, depth, archetype, parent row, and parent's archetype id when readable or -1. */ + let rowEntities = new Int32Array(0); + let depths = new Int32Array(0); + let rowParents = new Int32Array(0); + let keys = new Int32Array(0); + let owners = new Int32Array(0); + /** The matched rows' indexes, by depth, and where each depth ends. */ + let sortedRows = new Int32Array(0); + let counts = new Int32Array(0); + /** By index in `matches`: the index of its first row among the matched rows. */ + let bases = new Int32Array(0); + /** The groups of a build, laid out one after another, before the order lays them out with spare slots. */ + let builtRows = new Int32Array(0); + let builtParents = new Int32Array(0); + let builtEntities = new Int32Array(0); + let groupEnds = new Int32Array(0); + let groupDepths = new Int32Array(0); + let groupOwners = new Int32Array(0); + let groupParents = new Int32Array(0); + + /** The matched rows in depth order from the roots. Rows on or under a parent cycle are in none. */ + const levels = (matches: readonly Archetype[]): Order => { + update(matches); + if (unordered) buildOrder(matches); + else if (moved.length > 0) patch(); + moved = []; + movedPass++; + return order.current(); + }; + + /** Moves each entity whose slot may have changed: all removals first, so no two slots of a group share a row. */ + const patch = (): void => { + const { archetype: archetypeOf, row: rowOf } = state.entities; + const count = moved.length; + const targets = new Int32Array(4 * count); + const kept = new Uint8Array(count); + for (let k = 0; k < count; k++) { + const index = moved[k]; + const level = levelOf[index]; + targets[4 * k] = level < 1 ? -1 : level - 1; + if (level < 1) { + order.remove(index); + continue; + } + const parent = parentOf[index]; + const owner = ownerOf[archetypeOf[index]]; + const key = parent < 0 || !isReadable(archetypeOf[parent]) ? -1 : archetypeOf[parent]; + targets[4 * k + 1] = owner; + targets[4 * k + 2] = key; + targets[4 * k + 3] = parent < 0 ? 0 : rowOf[parent]; + if (order.isAt(index, level - 1, owner, key, rowOf[index])) kept[k] = 1; + else order.remove(index); + } + for (let k = 0; k < count; k++) { + const depth = targets[4 * k]; + if (depth < 0) continue; + const index = moved[k]; + if (kept[k] === 1) order.setParent(index, targets[4 * k + 3]); + else order.insert(index, depth, targets[4 * k + 1], targets[4 * k + 2], rowOf[index], targets[4 * k + 3]); + } + order.tidy(); + }; + + /** Builds the order from every matched row. */ + const buildOrder = (matches: readonly Archetype[]): void => { + let count = 0; + for (const archetype of matches) count += archetype.count; + const deepest = scanRows(matches, count); + sortByDepth(count, deepest); + const groups = groupRows(matches, deepest); + order.load(groups, builtRows, builtParents, builtEntities, groupEnds, groupDepths, groupOwners, groupParents, state.entities.length); + unordered = false; + }; + + // The loops below read typed arrays through local constants: V8 reloads closure variables on every access. + + /** + * Records each matched row's depth, archetype, parent row, and parent's archetype when readable or -1, by index + * among the matched rows, and flags the parents' archetypes. Returns the deepest depth. + */ + const scanRows = (matches: readonly Archetype[], count: number): number => { + const { archetype: archetypeOf, row: rowOf } = state.entities; + for (let id = 0; id < state.archetypes.length; id++) isReadable(id); + rowEntities = fit(rowEntities, count); + depths = fit(depths, count); + rowParents = fit(rowParents, count); + keys = fit(keys, count); + owners = fit(owners, count); + const levels = levelOf, parents = parentOf, known = readable; + const rowIndexes = rowEntities, rowDepths = depths, rowKeys = keys, rowOwners = owners, rowParentRows = rowParents; + let deepest = -1; + let global = 0; + for (let a = 0; a < matches.length; a++) { + const archetype = matches[a]; + const rows = archetype.entities; + rowOwners.fill(a, global, global + archetype.count); + for (let row = 0, end = archetype.count; row < end; row++, global++) { + const index = rows[row] >>> 0; + const depth = levels[index] - 1; + rowIndexes[global] = index; + rowDepths[global] = depth; + if (depth > deepest) deepest = depth; + const parent = parents[index]; + if (parent < 0) { + rowKeys[global] = -1; + continue; + } + const parentArchetype = archetypeOf[parent]; + rowParentRows[global] = rowOf[parent]; + rowKeys[global] = known[parentArchetype] === 1 ? parentArchetype : -1; + } + } + return deepest; + }; + + /** Orders the matched rows by depth, keeping storage order within each: `counts[d]` ends depth `d` in `sortedRows`. */ + const sortByDepth = (count: number, deepest: number): void => { + sortedRows = fit(sortedRows, count); + counts = fit(counts, deepest + 2); + counts.fill(0, 0, deepest + 2); + const ordered = sortedRows, ends = counts, rowDepths = depths; + for (let k = 0; k < count; k++) if (rowDepths[k] >= 0) ends[rowDepths[k] + 1]++; + for (let depth = 1; depth <= deepest + 1; depth++) ends[depth] += ends[depth - 1]; + for (let k = 0; k < count; k++) { + const depth = rowDepths[k]; + if (depth >= 0) ordered[ends[depth]++] = k; + } + }; + + /** + * Within each depth and archetype, which `order` already keeps together, groups rows by their parents' archetype, + * stably, so a kernel call binds one parent archetype's columns. Returns the number of groups. + */ + const groupRows = (matches: readonly Archetype[], deepest: number): number => { + const total = counts[deepest] ?? 0; + builtRows = fit(builtRows, total); + builtParents = fit(builtParents, total); + builtEntities = fit(builtEntities, total); + groupEnds = fit(groupEnds, total); + groupDepths = fit(groupDepths, total); + groupOwners = fit(groupOwners, total); + groupParents = fit(groupParents, total); + bases = fit(bases, matches.length); + bases[0] = 0; + for (let a = 1; a < matches.length; a++) bases[a] = bases[a - 1] + matches[a - 1].count; + const ordered = sortedRows, rowOwners = owners, rowKeys = keys, rowParentRows = rowParents, rowIndexes = rowEntities, starts = bases; + const toRows = builtRows, toParents = builtParents, toEntities = builtEntities; + let groups = 0; + const close = (end: number, depth: number, owner: number, parent: number): void => { + groupEnds[groups] = end; + groupDepths[groups] = depth; + groupOwners[groups] = owner; + groupParents[groups++] = parent; + }; + let depthStart = 0; + for (let depth = 0; depth <= deepest; depth++) { + const depthEnd = counts[depth]; + for (let start = depthStart; start < depthEnd;) { + const owner = rowOwners[ordered[start]]; + const base = starts[owner]; + const first = rowKeys[ordered[start]]; + let end = start + 1; + let uniform = true; + for (; end < depthEnd && rowOwners[ordered[end]] === owner; end++) if (rowKeys[ordered[end]] !== first) uniform = false; + if (uniform) { + for (let k = start; k < end; k++) { + const global = ordered[k]; + toRows[k] = global - base; + toParents[k] = rowParentRows[global]; + toEntities[k] = rowIndexes[global]; + } + close(end, depth, owner, first); + } + else { + let slot = start; + const parentKeys = [...new Set(ordered.subarray(start, end).map(k => rowKeys[k]))].sort((a, b) => a - b); + for (const parentKey of parentKeys) { + for (let k = start; k < end; k++) { + const global = ordered[k]; + if (rowKeys[global] !== parentKey) continue; + toRows[slot] = global - base; + toEntities[slot] = rowIndexes[global]; + toParents[slot++] = rowParentRows[global]; + } + close(slot, depth, owner, parentKey); + } + } + start = end; + } + depthStart = depthEnd; + } + return groups; + }; + + /** By entity index: whether the current filtered run marked it. */ + let dirty = new Uint8Array(0); + /** By depth: the entities the current filtered run marked. */ + const buckets: number[][] = []; + let marked: number[] = []; + let deepestMarked = -1; + /** A filtered run's rows and their parents' rows, in the arena so WASM kernels can address them. */ + let list: Int32Array = new Int32Array(0); + let parentList: Int32Array = new Int32Array(0); + let sortKeys = new Float64Array(0); + + const mark = (index: number): void => { + const level = levelOf[index]; + if (level < 1 || dirty[index] !== 0) return; + dirty[index] = 1; + marked.push(index); + (buckets[level - 1] ??= []).push(index); + if (level - 1 > deepestMarked) deepestMarked = level - 1; + }; + + const markChildren = (index: number): void => { + for (let child = firstChild[index]; child !== 0; child = nextChild[child - 1]) mark(child - 1); + }; + + /** Calls `each` with every row whose ticks of a component are newer than `since`, by its `added` or `changed` ticks. */ + const changedRows = (archetype: Archetype, id: number, added: boolean, since: number, each: (row: number) => void): void => { + const storage = archetype.storageById[id]; + if (storage === undefined) return; + const whole = storage.columnChanged > since; + const ticks = added ? storage.added : storage.changed; + const chunks = added ? storage.chunkAdded : storage.chunkChanged; + for (let chunk = 0, count = Math.ceil(archetype.count / 2 ** chunkShift); chunk < count; chunk++) { + if (!whole && !(chunks[chunk] > since)) continue; + for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { + if (whole || ticks[row] > since) each(row); + } + } + }; + + /** + * Marks the rows of a filtered run that may pass after `since`: those passing `added` or `changed`, whose parent + * reference changed, whose parent's `read` values changed, or that the journal says moved or changed parent. Returns + * null when none do. Each listed row marks its children, so a change reaches every row under it. + */ + const filtered = (matches: readonly Archetype[], added: readonly number[], changed: readonly number[], since: number): Pass | null => { + update(matches); + const length = state.entities.length; + if (dirty.length < length) dirty = new Uint8Array(Math.max(length, 2 * dirty.length)); + for (const bucket of buckets) if (bucket !== undefined) bucket.length = 0; + marked = []; + deepestMarked = -1; + for (const archetype of matches) { + const { entities } = archetype; + const markRow = (row: number): void => mark(entities[row] >>> 0); + if (rebuilt) for (let row = 0; row < archetype.count; row++) markRow(row); + for (const id of added) changedRows(archetype, id, true, since, markRow); + for (const id of [...changed, through]) changedRows(archetype, id, false, since, markRow); + } + if (read.length > 0) { + for (const archetype of state.archetypes) { + if (archetype.count === 0 || !isReadable(archetype.id)) continue; + const { entities } = archetype; + for (const id of read) changedRows(archetype, id, false, since, row => markChildren(entities[row] >>> 0)); + } + } + for (const index of touched) mark(index); + touched = []; + rebuilt = false; + if (marked.length === 0) return null; + let matched = 0; + for (const archetype of matches) matched += archetype.count; + list = fitArena(state.memory, list, matched); + parentList = fitArena(state.memory, parentList, matched); + let listed = 0; + let depth = 0; + const { archetype: archetypeOf, row: rowOf } = state.entities; + + /** Lists a depth's marked rows by scanning its groups in order. */ + const scan = (runs: Run[]): void => { + const ordered = levels(matches); + if (depth >= ordered.levels) return; + for (let group = ordered.depthGroups[depth]; group < ordered.depthGroups[depth + 1]; group++) { + const owner = ordered.groupOwners[group]; + const first = listed; + for (let slot = ordered.groupStarts[group]; slot < ordered.groupEnds[group]; slot++) { + const index = ordered.entities[slot]; + if (dirty[index] === 0) continue; + markChildren(index); + list[listed] = ordered.rows[slot]; + parentList[listed++] = ordered.parents[slot]; + } + if (listed > first) runs.push({ owner, parent: ordered.groupParents[group], rows: list, parents: parentList, start: first, end: listed }); + } + }; + + /** Lists a depth's marked rows by sorting them by archetype, parent archetype and row. */ + const sort = (bucket: readonly number[], runs: Run[]): void => { + const byOwner = new Map(); + for (const index of bucket) { + const owner = ownerOf[archetypeOf[index]]; + let entries = byOwner.get(owner); + if (entries === undefined) byOwner.set(owner, entries = []); + entries.push(index); + } + for (const owner of [...byOwner.keys()].sort((a, b) => a - b)) { + const entries = byOwner.get(owner) ?? []; + if (sortKeys.length < entries.length) sortKeys = new Float64Array(Math.max(entries.length, 2 * sortKeys.length)); + const sorting = sortKeys.subarray(0, entries.length); + for (let k = 0; k < entries.length; k++) { + const index = entries[k]; + const parent = parentOf[index]; + const key = parent < 0 || !isReadable(archetypeOf[parent]) ? -1 : archetypeOf[parent]; + sorting[k] = (key + 1) * rowLimit + rowOf[index]; + } + sorting.sort(); + const entities = matches[owner].entities; + let first = listed; + let current = -2; + for (let k = 0; k < sorting.length; k++) { + const key = Math.floor(sorting[k] / rowLimit) - 1; + const row = sorting[k] % rowLimit; + if (key !== current) { + if (listed > first) runs.push({ owner, parent: current, rows: list, parents: parentList, start: first, end: listed }); + first = listed; + current = key; + } + const index = entities[row] >>> 0; + markChildren(index); + list[listed] = row; + parentList[listed++] = key < 0 ? 0 : rowOf[parentOf[index]]; + } + if (listed > first) runs.push({ owner, parent: current, rows: list, parents: parentList, start: first, end: listed }); + } + }; + + const next = (): readonly Run[] | null => { + while (depth <= deepestMarked && (buckets[depth]?.length ?? 0) === 0) depth++; + if (depth > deepestMarked) return null; + const bucket = buckets[depth]; + const runs: Run[] = []; + const dense = bucket.length > Math.max(denseMinimum, matched * denseShare); + if (dense || matched >= rowLimit) scan(runs); + else sort(bucket, runs); + depth++; + return runs; + }; + const clear = (): void => { + for (const index of marked) dirty[index] = 0; + }; + return { next, clear }; + }; + + /** Frees what it holds in the arena and stops its reading of the journal, once nothing runs it again. */ + const release = (): void => { + state.journal.readers--; + order.release(); + if (list.length > 0) freeArray(state.memory, list); + if (parentList.length > 0) freeArray(state.memory, parentList); + list = parentList = new Int32Array(0); + }; + + return { levels, filtered, release }; +}; diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts new file mode 100644 index 000000000..fcdba6b89 --- /dev/null +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { definitionOf } from "../../interface/registry/index.js"; +import { kernelOf } from "./attach-kernel.js"; +import { readEach, readEntity, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { bindKernel } from "./kernels.js"; +import { createState } from "./state.js"; +import { workers } from "../../interface/resources/workers.js"; + +type Vec2 = readonly [number, number]; +const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const satisfies Schema; + +const position = Symbol("wasmTest/position"); +const velocity = Symbol("wasmTest/velocity"); +const bounces = Symbol("wasmTest/bounces"); +const flipped = Symbol("wasmTest/flipped"); +const score = Symbol("wasmTest/score"); +const seconds = Symbol("wasmTest/seconds"); +const floor = Symbol("wasmTest/floor"); +const target = Symbol("wasmTest/target"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number] }; + [velocity]: { type: readonly [number, number] }; + [bounces]: { type: number }; + [flipped]: { type: boolean }; + [score]: { type: number }; + [target]: { type: Entity }; + } + export interface Resources { + [seconds]: { type: number }; + [floor]: { type: number }; + } +} + +Component.register(position, { type: { ...vec2, layout: "storage" } }); +Component.register(velocity, { type: vec2 }); +Component.register(bounces, { type: { type: "integer", minimum: -2147483648, maximum: 2147483647 } }); +Component.register(flipped, { type: { type: "boolean" } }); +Component.register(score, { type: { type: "number" } }); +Component.register(target, { type: Entity.schema }); +Resource.register(seconds, { type: { type: "number", default: 1 / 60 } }); +Resource.register(floor, { type: { type: "number", precision: 1, default: -2.5 } }); + +const seed = System.create({ name: "wasmTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + let previous = Entity.none; + for (let i = 0; i < input.count; i++) { + previous = commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: [i * 0.37, (i % 13) * 0.71], [velocity]: [Math.sin(i) * 3, Math.cos(i) * 5], [bounces]: 0, [flipped]: false, [score]: 0, [target]: previous }); + } +}); + +/** Despawns every seventh entity and spawns replacements, so some targets go stale and their indexes are reused. */ +const cull = System.create({ name: "wasmTest/cull", input: { type: "object", properties: { frame: { type: "integer" } } }, components: { read: [position], has: [bounces] } }, (row, { input, commands }) => { + if ((row.entity >>> 0) % 7 !== input.frame % 7) return; + commands.despawn(row.entity); + commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: row[position], [velocity]: [1, 1], [bounces]: 0, [flipped]: true, [score]: 0, [target]: row.entity }); +}); + +const halve = (value: number) => value * 0.5; + +const integrate = System.create({ + name: "wasmTest/integrate", + kernel: "cpu", + components: { write: [position, velocity] }, + resources: { read: [seconds] }, +}, (row, { resources }) => { + const [x, y] = row[position]; + const [vx, vy] = row[velocity]; + const t = resources[seconds]; + row[velocity] = [vx * 0.999, vy - 9.8 * t]; + row[position] = [x + vx * t, y + halve(vy) * t * 2]; +}); + +const bounce = System.create({ + name: "wasmTest/bounce", + kernel: "cpu", + components: { write: [position, velocity, bounces, flipped] }, + resources: { read: [floor] }, + after: [integrate], +}, (row, { resources }) => { + const [x, y] = row[position]; + const [vx, vy] = row[velocity]; + if (y < resources[floor] && vy < 0) { + row[position] = [x, resources[floor] + Math.abs(y - resources[floor])]; + row[velocity] = [vx, Math.min(-vy * 0.8, 40)]; + row[bounces] = row[bounces] + 1; + row[flipped] = !row[flipped]; + } + else if (x % 7 > 6.5) { + row[flipped] = row[flipped] === true ? false : true; + } +}); + +const tally = System.create({ + name: "wasmTest/tally", + kernel: "cpu", + components: { read: [bounces, flipped, position], write: [score] }, + after: [bounce], +}, row => { + let total = 0; + for (let i = 0; i < 10; i++) { + if (i === 7) break; + if (i % 2 === 1) continue; + total += i * row[bounces]; + } + const [x] = row[position]; + row[score] = total + (row[flipped] ? Math.floor(x * 100) / 100 : Math.round(x) | 0) + (x > 3 && x < 9 ? 1 : 0); +}); + +const scale = System.create({ + name: "wasmTest/scale", + kernel: "cpu", + input: { type: "object", properties: { factor: { type: "number" } } }, + components: { write: [score] }, +}, (row, { input }) => { + row[score] = row[score] * input.factor; +}); + +/** Component-wise vector math: one f32x4 operation per row. */ +const drift = System.create({ + name: "wasmTest/drift", + kernel: "cpu", + components: { read: [velocity], write: [position] }, + resources: { read: [seconds] }, + after: [tally], +}, (row, { resources }) => { + const [x, y] = row[position]; + const [vx, vy] = row[velocity]; + const t = resources[seconds]; + row[position] = [x + vx * t, y + vy * t]; +}); + +/** Runs only for rows whose bounces changed since it last ran. */ +const react = System.create({ + name: "wasmTest/react", + kernel: "cpu", + components: { write: [score], changed: [bounces] }, + after: [tally], +}, row => { + row[score] = row[score] + 1000; +}); + +/** A lookup per row: found targets pull, missing or stale ones cost a point. */ +const chase = System.create({ + name: "wasmTest/chase", + kernel: "cpu", + components: { read: [target], write: [score] }, + lookup: { targets: { read: [position, flipped] } }, + after: [react, drift], +}, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + if (other === null) { + row[score] = row[score] - 1; + return; + } + const [x, y] = other[position]; + row[score] = row[score] + (other[flipped] ? x : y); +}); + +const main = System.create({ name: "wasmTest/main", systems: [integrate, bounce, tally, drift, react, chase] }); + +const snapshotted = [position, velocity, bounces, flipped, score] as const; + +/** Each entity's values in `snapshotted` order, as arrays, since JSON leaves out symbol keys. */ +const snapshot = async (world: World): Promise => + Promise.all((await select(world, { required: [position] })).map(async entity => { + const values = await readEntity(world, entity, snapshotted); + return snapshotted.map(key => values?.[key]); + })); + + +const simulate = async (wasm: boolean, simd = false): Promise => { + const world = createWorld({ [workers]: 0 }, { wasm, simd }); + await world.run(seed, { count: 300 }); + for (let frame = 0; frame < 120; frame++) { + await world.run(main); + if (frame % 30 === 0) await world.run(scale, { factor: 1.5 }); + if (frame % 20 === 10) await world.run(cull, { frame }); + } + const result = await snapshot(world); + await world.dispose(); + return result; +}; + +/** Counts the runs of rows whose score changed. */ +const recount = System.create({ name: "wasmTest/recount", kernel: "cpu", components: { write: [bounces], changed: [score] } }, row => { + row[bounces] += 1; +}); +const chosen = new Set(); +const rescore = System.create({ name: "wasmTest/rescore", components: { write: [score] } }, row => { + if (chosen.has(row.entity)) row[score] += 1; +}); + +/** Keeps ticks of `score` without observing its changes, so kernels writing it mute their stamps. */ +const scoreAdded = System.create({ name: "wasmTest/scoreAdded", components: { added: [score] } }, () => {}); +const raise = System.create({ name: "wasmTest/raise", kernel: "cpu", components: { write: [score] } }, row => { + row[score] += 1; +}); +let raised = 0; +const countRaised = System.create({ name: "wasmTest/countRaised", components: { changed: [score] } }, () => { raised++; }); + +describe("WASM kernels", () => { + it("stamp what they write once a change filter observes a component that already kept ticks", async () => { + for (const simd of [true, false]) { + await using ecs = createWorld({ [workers]: 0 }, { simd }); + await ecs.run(seed, { count: 100 }); + await ecs.run(scoreAdded); + await ecs.run(raise); + raised = 0; + // Observing `score` loads `raise` again with its stamps, while `score`'s columns stay where they were. + await ecs.run(countRaised); + await ecs.run(raise); + await ecs.run(countRaised); + expect(raised, `simd ${simd}`).toBe(200); + } + }); + + it("pass exactly the rows a change filter passes, across blocks of rows and their edges", async () => { + const rows = [0, 3, 7, 8, 15, 16, 40, 41, 42, 43, 44, 45, 46, 47, 63, 64, 98, 99]; + for (const [wasm, simd] of [[true, true], [true, false], [false, false]] as const) { + await using ecs = createWorld({ [workers]: 0 }, { wasm, simd }); + await ecs.run(seed, { count: 100 }); + await ecs.run(recount); + const entities = await select(ecs, { required: [score] }); + chosen.clear(); + for (const row of rows) chosen.add(entities[row]); + await ecs.run(rescore); + await ecs.run(recount); + const ran = (await readEach(ecs, entities, bounces)).flatMap((value, row) => value === 2 ? [row] : []); + expect(ran, `wasm ${wasm}, simd ${simd}`).toEqual(rows); + } + }); + + + it("are generated for every kernel here, with SIMD only where lanes line up", () => { + const wasmOf = (system: typeof integrate | typeof bounce | typeof tally | typeof scale | typeof drift | typeof chase) => { + const definition = definitionOf(system); + if (definition === undefined || definition.kind === "composite") throw new Error("not a leaf"); + const description = kernelOf(system); + if (description === undefined) throw new Error("not compiled"); + return bindKernel(createState(), definition, description).wasm; + }; + expect([integrate, bounce, tally, scale, drift].map(system => wasmOf(system)?.vectorized)).toEqual([false, false, false, false, true]); + expect(wasmOf(chase)).not.toBeNull(); + }); + + it("give bit-identical results to the JS kernels without SIMD", async () => { + const javascript = await simulate(false); + expect(javascript.some(values => JSON.stringify(values).includes("true"))).toBe(true); + expect(await simulate(true)).toEqual(javascript); + }); + + it("stay within storage precision of the JS kernels with SIMD", async () => { + const javascript = await simulate(false); + const vector = await simulate(true, true); + expect(vector).toHaveLength(javascript.length); + const leaves = (value: unknown): number[] => JSON.stringify(value).match(/-?[\d.]+(?:e[-+]?\d+)?/g)?.map(Number) ?? []; + const relative = leaves(vector).map((value, i) => Math.abs(value - leaves(javascript)[i]) / Math.max(1, Math.abs(leaves(javascript)[i]))); + expect(Math.max(...relative)).toBeLessThan(1e-4); + expect(vector).not.toEqual(javascript); + }); +}); diff --git a/packages/ecs/src/implementation/world/wasm.test.ts b/packages/ecs/src/implementation/world/wasm.test.ts new file mode 100644 index 000000000..478906012 --- /dev/null +++ b/packages/ecs/src/implementation/world/wasm.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from "vitest"; +import type { Program } from "./kernel.js"; +import { generateWasm, importsOf, type WasmColumn } from "./wasm.js"; + +type Expression = Program.Expression; + +const views = { f32: Float32Array, f64: Float64Array, i32: Int32Array, u32: Uint32Array, boolean: Uint8Array } as const; +type Output = keyof typeof views; + +/** A dense column; each test column is its own component. */ +const dense = (output: Output, component: number): WasmColumn => output === "boolean" + ? { view: "u8", stride: 1, offset: 0, boolean: true, component } + : { view: output, stride: output === "f64" ? 8 : 4, offset: 0, boolean: false, component }; + +/** Runs a program over one row per input value; column 0 is the input (f64), column 1 the output of kind `output`. */ +const run = (output: Output, value: Expression, inputs: readonly number[]): number[] | null => { + const program: Program = { locals: 0, body: [{ kind: "store", column: 1, value }] }; + const kernel = generateWasm(program, [dense("f64", 0), dense(output, 1)], () => "number", false); + if (kernel === null) return null; + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + const input = new Float64Array(memory.buffer, 0, inputs.length); + input.set(inputs); + const result = new views[output](memory.buffer, 4096, inputs.length); + const instance = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }); + const runKernel: unknown = instance.exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(0, inputs.length, 0, -1, 0, 4096); + return Array.from(result, Number); +}; + +const input: Expression = { kind: "column", column: 0 }; +const edges = [0, -0, 0.5, -0.5, 3.7, -3.7, 2 ** 31, -(2 ** 31) - 1, 2 ** 32 + 5, -(2 ** 32) - 5, 2 ** 53 + 2, 1e20, -1e300, NaN, Infinity, -Infinity, 1 / 3]; + +/** The same store through a typed array, which is what the JS kernel does. */ +const viaTypedArray = (output: keyof typeof views, values: readonly number[]): number[] => Array.from(new views[output](values), Number); + +describe("generateWasm", () => { + it("stores with typed-array conversion, for every edge value", () => { + for (const output of ["f32", "f64", "i32", "u32", "boolean"] as const) { + expect([output, run(output, input, edges)]).toEqual([output, viaTypedArray(output, edges)]); + } + }); + + it("matches JS for bitwise operators, which convert through ToInt32", () => { + const cases: [Program.Expression, (x: number) => number][] = [ + [{ kind: "binary", operator: "|", left: input, right: { kind: "number", value: 0 } }, x => x | 0], + [{ kind: "binary", operator: ">>>", left: input, right: { kind: "number", value: 1 } }, x => x >>> 1], + [{ kind: "binary", operator: "<<", left: input, right: { kind: "number", value: 33 } }, x => x << 33], + [{ kind: "unary", operator: "~", operand: input }, x => ~x], + [{ kind: "math", name: "imul", arguments: [input, { kind: "number", value: 3 }] }, x => Math.imul(x, 3)], + [{ kind: "math", name: "clz32", arguments: [input] }, x => Math.clz32(x)], + ]; + for (const [expression, expected] of cases) expect(run("f64", expression, edges)).toEqual(edges.map(expected)); + }); + + it("matches JS truthiness, including NaN and -0, and returns operand values from && and ||", () => { + const truthy: Expression = { kind: "conditional", test: input, consequent: { kind: "number", value: 1 }, alternate: { kind: "number", value: 2 } }; + expect(run("f64", truthy, edges)).toEqual(edges.map(x => x ? 1 : 2)); + const and: Expression = { kind: "logical", operator: "&&", left: input, right: { kind: "number", value: 7 } }; + expect(run("f64", and, edges)).toEqual(edges.map(x => x && 7)); + const or: Expression = { kind: "logical", operator: "||", left: input, right: { kind: "number", value: 7 } }; + expect(run("f64", or, edges)).toEqual(edges.map(x => x || 7)); + }); + + it("matches JS for imported and native Math", () => { + const cases: [Expression, (x: number) => number][] = [ + [{ kind: "binary", operator: "%", left: input, right: { kind: "number", value: 2.5 } }, x => x % 2.5], + [{ kind: "binary", operator: "**", left: input, right: { kind: "number", value: 0.5 } }, x => x ** 0.5], + [{ kind: "math", name: "round", arguments: [input] }, x => Math.round(x)], + [{ kind: "math", name: "sin", arguments: [input] }, x => Math.sin(x)], + [{ kind: "math", name: "max", arguments: [input, { kind: "number", value: -0 }] }, x => Math.max(x, -0)], + [{ kind: "math", name: "fround", arguments: [input] }, x => Math.fround(x)], + ]; + for (const [expression, expected] of cases) expect(run("f64", expression, edges)).toEqual(edges.map(expected)); + }); + + it("never strictly equates a boolean and a number, and falls back when it can't tell", () => { + const booleanVersusNumber: Expression = { kind: "binary", operator: "===", left: { kind: "boolean", value: true }, right: { kind: "number", value: 1 } }; + expect(run("f64", booleanVersusNumber, [0])).toEqual([0]); + const mixed: Expression = { kind: "logical", operator: "&&", left: input, right: { kind: "boolean", value: true } }; + expect(run("f64", { kind: "binary", operator: "===", left: mixed, right: { kind: "boolean", value: true } }, [0])).toBeNull(); + }); + + it("addresses interleaved columns by stride and offset, leaving the rest of each element untouched", () => { + // Column 1 is the z of a WGSL-storage vec3: 16-byte elements, at byte 8. + const kernel = generateWasm({ locals: 0, body: [{ kind: "store", column: 1, value: input }] }, [dense("f64", 0), { view: "f32", stride: 16, offset: 8, boolean: false, component: 1 }], () => "number", false); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + new Float64Array(memory.buffer, 0, 3).set([1.5, 2.5, 3.5]); + const output = new Float32Array(memory.buffer, 4096, 12).fill(-1); + const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(0, 3, 0, -1, 0, 4096); + expect([...output]).toEqual([-1, -1, 1.5, -1, -1, -1, 2.5, -1, -1, -1, 3.5, -1]); + }); + + it("branches like structured WASM: a loop summing 1 to the input, with next ending the row", () => { + const local = (index: number): Expression => ({ kind: "local", local: index }); + const program: Program = { + locals: 2, + body: [ + { kind: "set", local: 0, value: { kind: "number", value: 0 } }, + { kind: "set", local: 1, value: { kind: "number", value: 1 } }, + { kind: "if", test: { kind: "binary", operator: "<", left: input, right: { kind: "number", value: 0 } }, consequent: [{ kind: "next" }], alternate: [] }, + { + kind: "block", body: [{ + kind: "loop", body: [ + { kind: "branchIf", depth: 1, test: { kind: "binary", operator: ">", left: local(1), right: input } }, + { kind: "set", local: 0, value: { kind: "binary", operator: "+", left: local(0), right: local(1) } }, + { kind: "set", local: 1, value: { kind: "binary", operator: "+", left: local(1), right: { kind: "number", value: 1 } } }, + { kind: "branch", depth: 0 }, + ], + }], + }, + { kind: "store", column: 1, value: local(0) }, + ], + }; + const kernel = generateWasm(program, [dense("f64", 0), dense("f64", 1)], () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + new Float64Array(memory.buffer, 0, 3).set([4, -1, 10]); + const output = new Float64Array(memory.buffer, 4096, 3).fill(99); + const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(0, 3, 0, -1, 0, 4096); + expect([...output]).toEqual([10, 99, 55]); + }); +}); + +describe("generateWasm with SIMD", () => { + /** Runs a program over `rows` rows; `layout` gives each column's element stride, offset and component. */ + const runVector = (program: Program, columns: readonly WasmColumn[], fill: (views: Float32Array[], ticks: Float64Array) => void, rows: number, tick = 7) => { + const kernel = generateWasm(program, columns, () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + const bases = columns.map(column => 4096 * (Math.max(0, column.component) + 1)); + const floats = columns.map((_, k) => new Float32Array(memory.buffer, bases[k], 512)); + const ticks = new Float64Array(memory.buffer, 4096 * 8, 64); + const chunks = new Float64Array(memory.buffer, 4096 * 9, 4); + fill(floats, ticks); + const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + // f64 columns are ticks, then their chunks. + runKernel(0, rows, tick, -1, ...bases.map((base, k) => columns[k].view !== "f64" ? base : columns[k].component === -2 ? 4096 * 9 : 4096 * 8)); + return { kernel, floats, ticks, chunks }; + }; + const column = (component: number, stride: number, offset: number): WasmColumn => ({ view: "f32", stride, offset, boolean: false, component }); + const read = (k: number): Expression => ({ kind: "column", column: k }); + const add = (left: Expression, right: Expression): Expression => ({ kind: "binary", operator: "+", left, right }); + + it("adds dense f32 columns four rows at a time, then the tail", () => { + const { kernel, floats } = runVector( + { locals: 0, body: [{ kind: "store", column: 2, value: add(read(0), read(1)) }] }, + [column(0, 4, 0), column(1, 4, 0), column(2, 4, 0)], + ([a, b, out]) => { a.set(Array.from({ length: 12 }, (_, i) => i + 0.1)); b.set(Array.from({ length: 12 }, (_, i) => i * 3.7)); out.fill(-1); }, + 10, + ); + expect(kernel.vectorized).toBe(true); + const expected = Array.from({ length: 10 }, (_, i) => Math.fround(Math.fround(i + 0.1) + Math.fround(i * 3.7))); + expect([...floats[2].subarray(0, 12)]).toEqual([...expected, -1, -1]); + }); + + for (const [name, stride, leafCount] of [["vec2 (8-byte stride)", 8, 2], ["tight vec3 (12-byte stride)", 12, 3], ["WGSL storage vec3 (16-byte stride with padding)", 16, 3]] as const) { + it(`deinterleaves and reinterleaves a ${name}`, () => { + const words = stride / 4; + // p += v, leaf by leaf; columns 0..2 are p.x/y/z, 3..5 are v.x/y/z. + const leaves = Array.from({ length: leafCount }, (_, leaf) => leaf); + const program: Program = { locals: 0, body: leaves.map(leaf => ({ kind: "store", column: leaf, value: add(read(leaf), read(leafCount + leaf)) })) }; + const columns = [...leaves.map(leaf => column(0, stride, 4 * leaf)), ...leaves.map(leaf => column(1, stride, 4 * leaf))]; + const { kernel, floats } = runVector(program, columns, views => { + const p = views[0]; + const v = views[leafCount]; + for (let i = 0; i < 64; i++) p[i] = words === 4 && i % 4 === 3 ? -9 : i; + for (let i = 0; i < 64; i++) v[i] = 100 + i; + }, 10); + expect(kernel.vectorized).toBe(true); + const p = floats[0]; + for (let row = 0; row < 12; row++) { + for (let word = 0; word < words; word++) { + const index = row * words + word; + const padding = word === 3; + const expected = padding ? -9 : row < 10 ? index + 100 + index : index; + expect([row, word, p[index]]).toEqual([row, word, expected]); + } + } + }); + } + + for (const [name, stride] of [["vec2", 8], ["tight vec3", 12]] as const) { + it(`runs whole ${name} elements four rows at a time from any start, then the tail`, () => { + const leaves = stride / 4; + const local = (index: number): Expression => ({ kind: "local", local: index }); + // const [p…] = p; const [v…] = v; p = [p + v * args + (leaf + 1), …]; stamp p's ticks. + const program: Program = { + locals: 2 * leaves, + body: [ + ...Array.from({ length: 2 * leaves }, (_, index): Program.Statement => ({ kind: "set", local: index, value: read(index) })), + ...Array.from({ length: leaves }, (_, leaf): Program.Statement => ({ + kind: "store", + column: leaf, + value: add(add(local(leaf), { kind: "binary", operator: "*", left: local(leaves + leaf), right: { kind: "args", path: [] } }), { kind: "number", value: leaf + 1 }), + })), + { kind: "stamp", column: 2 * leaves, chunks: 2 * leaves + 1 }, + ], + }; + const columns: WasmColumn[] = [ + ...Array.from({ length: leaves }, (_, leaf) => column(0, stride, 4 * leaf)), + ...Array.from({ length: leaves }, (_, leaf) => column(1, stride, 4 * leaf)), + { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }, + { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }, + ]; + const kernel = generateWasm(program, columns, () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + expect(kernel.vectorized).toBe(true); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + const p = new Float32Array(memory.buffer, 4096, 32 * leaves); + const v = new Float32Array(memory.buffer, 8192, 32 * leaves); + const ticks = new Float64Array(memory.buffer, 16384, 32).fill(1); + for (let i = 0; i < p.length; i++) { p[i] = i; v[i] = 2 * i + 100; } + const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + const [start, end] = [3, 22]; + runKernel(start, end, 7, -1, ...columns.slice(0, leaves).map(() => 4096), ...columns.slice(0, leaves).map(() => 8192), 16384, 20480, 0.5); + const row = (index: number): number => Math.floor(index / leaves); + expect([...p]).toEqual([...p].map((_, i) => row(i) >= start && row(i) < end ? i + (2 * i + 100) * 0.5 + (i % leaves) + 1 : i)); + expect([...ticks]).toEqual([...ticks].map((_, i) => i >= start && i < end ? 7 : 1)); + }); + } + + it("blends stores and tick stamps under an if, per lane", () => { + const positive: Expression = { kind: "binary", operator: ">", left: read(0), right: { kind: "number", value: 0 } }; + const program: Program = { locals: 0, body: [{ kind: "if", test: positive, consequent: [{ kind: "store", column: 1, value: read(0) }, { kind: "stamp", column: 2, chunks: 3 }], alternate: [] }] }; + const ticksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }; + const chunksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }; + const inputs = [1, -1, 2, -2, 0, 3, NaN, 4, -5, 6]; + const { kernel, floats, ticks, chunks } = runVector(program, [column(0, 4, 0), column(1, 4, 0), ticksColumn, chunksColumn], ([input, out], tickValues) => { + input.set(inputs); + out.fill(-7); + tickValues.fill(1); + }, 10); + expect(kernel.vectorized).toBe(true); + expect([...floats[1].subarray(0, 10)]).toEqual(inputs.map(x => x > 0 ? x : -7)); + expect([...ticks.subarray(0, 10)]).toEqual(inputs.map(x => x > 0 ? 7 : 1)); + expect(chunks[0]).toBe(7); + }); + + it("stays scalar for operations without an exact f32x4 equivalent", () => { + const remainder: Expression = { kind: "binary", operator: "%", left: read(0), right: { kind: "number", value: 2 } }; + const kernel = generateWasm({ locals: 0, body: [{ kind: "store", column: 1, value: remainder }] }, [column(0, 4, 0), column(1, 4, 0)], () => "number"); + expect(kernel?.vectorized).toBe(false); + }); +}); + +describe("generateWasm with change filters", () => { + it("runs only rows with a newer tick, skipping chunks whose tick isn't newer", () => { + for (const simd of [false, true]) { + // out = in, for rows whose filter tick is newer than lastRun 5. + const columns: WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f32", stride: 4, offset: 0, boolean: false, component: 1 }]; + const kernel = generateWasm({ locals: 0, body: [{ kind: "store", column: 1, value: { kind: "column", column: 0 } }] }, columns, () => "number", simd, 1); + if (kernel === null) throw new Error("expected a kernel"); + const rows = 2100; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 65536, shared: true }); + const input = new Float32Array(memory.buffer, 0, rows); + for (let row = 0; row < rows; row++) input[row] = row; + const output = new Float32Array(memory.buffer, 16384, rows).fill(-1); + const ticks = new Float64Array(memory.buffer, 32768, rows); + const chunks = new Float64Array(memory.buffer, 65536, 3); + // Chunk 0's chunk tick is stale-new (8) but its rows are old; chunk 1 has three new rows; chunk 2 is old throughout. + chunks.set([8, 9, 3]); + for (const row of [1030, 1500, 2047]) ticks[row] = 9; + ticks[2050] = 9; + const run = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(0, rows, 10, 5, 0, 16384, 32768, 65536); + const written = [...output].flatMap((value, row) => value === -1 ? [] : [row]); + // Row 2050 has a new tick, but its chunk says nothing in it changed, so it's skipped by design. + expect([simd, kernel.vectorized, written]).toEqual([simd, false, [1030, 1500, 2047]]); + // lastRun below 0 passes everything. + output.fill(-1); + run(0, rows, 10, -1, 0, 16384, 32768, 65536); + expect(output.every((value, row) => value === row)).toBe(true); + } + }); +}); + +describe("generateWasm chunk stamps", () => { + const columns: WasmColumn[] = [ + { view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, + { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }, + { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }, + ]; + const run = (program: Program, start: number, end: number): number[] => { + const kernel = generateWasm(program, columns, () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 4, maximum: 65536, shared: true }); + new Float32Array(memory.buffer, 0, 4096).fill(1); + const chunks = new Float64Array(memory.buffer, 65536, 4); + const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(start, end, 7, -1, 0, 32768, 65536); + return [...chunks]; + }; + const stamp: Program.Statement = { kind: "stamp", column: 1, chunks: 2 }; + + it("stamps every chunk of the range once when every row stamps", () => { + expect(run({ locals: 0, body: [stamp] }, 1000, 2100)).toEqual([7, 7, 7, 0]); + expect(run({ locals: 0, body: [stamp] }, 5, 5)).toEqual([0, 0, 0, 0]); + }); + + it("stamps per row when a row can end before its stamp", () => { + // Rows of chunk 1 end early, so only chunks 0 and 2 are stamped. + const skipWhereTwo: Program.Statement = { + kind: "if", + test: { kind: "binary", operator: "==", left: { kind: "column", column: 0 }, right: { kind: "number", value: 2 } }, + consequent: [{ kind: "next" }], + alternate: [], + }; + const body: Program.Statement[] = [skipWhereTwo, stamp]; + const kernel = generateWasm({ locals: 0, body }, columns, () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 4, maximum: 65536, shared: true }); + const values = new Float32Array(memory.buffer, 0, 4096).fill(1); + values.fill(2, 1024, 2048); + const chunks = new Float64Array(memory.buffer, 65536, 4); + const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(0, 3000, 7, -1, 0, 32768, 65536); + expect([...chunks]).toEqual([7, 0, 7, 0]); + }); +}); + +describe("generateWasm integer remainder", () => { + it("matches JS % for integers, including -0, with or without the import", () => { + const values = [0, 7, -7, 10, -10, 2147483647, -2147483648, 4294967295, 123456]; + for (const view of ["i32", "u32"] as const) { + for (const divisor of [3, -3, 1, 10, 1000]) { + const columns: WasmColumn[] = [{ view, stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f64", stride: 8, offset: 0, boolean: false, component: 1 }]; + const remainder: Expression = { kind: "binary", operator: "%", left: { kind: "column", column: 0 }, right: { kind: "number", value: divisor } }; + const kernel = generateWasm({ locals: 0, body: [{ kind: "store", column: 1, value: remainder }] }, columns, () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + expect(kernel.imports).toEqual([]); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + const input = view === "i32" ? new Int32Array(memory.buffer, 0, values.length) : new Uint32Array(memory.buffer, 0, values.length); + input.set(values); + const output = new Float64Array(memory.buffer, 4096, values.length); + const run = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(0, values.length, 0, -1, 0, 4096); + expect([view, divisor, [...output]]).toEqual([view, divisor, [...input].map(x => x % divisor)]); + } + } + }); +}); + +describe("generateWasm reductions", () => { + it("start each accumulator at its partial, fold every row into it, and store it back", () => { + // Local 0 sums the input and local 1 takes its least, from partials 10 and 5. + const program: Program = { + locals: 2, + reductions: [0, 1], + body: [ + { kind: "set", local: 0, value: { kind: "binary", operator: "+", left: { kind: "local", local: 0 }, right: input } }, + { kind: "set", local: 1, value: { kind: "math", name: "min", arguments: [{ kind: "local", local: 1 }, input] } }, + ], + }; + for (const simd of [false, true]) { + const kernel = generateWasm(program, [dense("f64", 0)], () => "number", simd); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + new Float64Array(memory.buffer, 0, 9).set([3, -2, 7, 1, 8, 0.5, 4, 6, 2]); + const partials = new Float64Array(memory.buffer, 4096, 2); + partials.set([10, 5]); + const instance = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }); + const runKernel: unknown = instance.exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(0, 9, 0, -1, 0, 4096); + expect([...partials]).toEqual([10 + 29.5, -2]); + expect(kernel.vectorized).toBe(false); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/wasm.ts b/packages/ecs/src/implementation/world/wasm.ts new file mode 100644 index 000000000..a91845a81 --- /dev/null +++ b/packages/ecs/src/implementation/world/wasm.ts @@ -0,0 +1,2093 @@ +import { chunkShift, type Program } from './kernel.js'; +import type { Reduction } from '../../interface/resource/reduction.js'; +import type { View } from '../../interface/registry/layout.js'; +import { withoutIntrinsics } from './intrinsics.js'; + +type Expression = Program.Expression; +type Statement = Program.Statement; + +/** What a value is, for strict equality: JS `true === 1` is false, but both are 1 here. */ +export type Type = "number" | "boolean" | "mixed"; + +/** + * Where column `k`'s value for a row is: at its address plus `row * stride + offset` bytes, through `view`. + * Entity and tick columns are dense f64. `boolean` columns read as 0 or 1. + */ +export type WasmColumn = { + readonly view: View; + readonly stride: number; + readonly offset: number; + readonly boolean: boolean; + /** Columns of one component share its bytes; -1 for entity and tick columns. */ + readonly component: number; + /** A tick column nothing observes: its stamps are dropped. */ + readonly muted?: boolean; +}; + +/** A scalar parameter: a leaf of a resource or of `args`, in `scalars` order. */ +export type WasmScalar = { readonly source: "resource"; readonly index: number; readonly path: readonly (number | string)[] } | { readonly source: "args"; readonly path: readonly (number | string)[] }; + +/** + * A lookup's directory in shared memory: one record of `1 + reads + watched` i32 words per archetype id, a match flag, + * then the column address of each component the lookup reads, then the `changed` tick column address of each it + * watches, 0 for one keeping no ticks. `leaf` places a scalar leaf of read component `read`; read `reads` of a lookup + * that watches is its `changed`. + */ +export type WasmLookup = { + readonly reads: number; + readonly watched?: number; + readonly leaf: (read: number, path: readonly (number | string)[]) => Omit | null; +}; + +/** Where a probe's value goes in the key's words, and the stored type it's converted to first. */ +export type WasmKeyValue = { readonly word: number; readonly scalar: "f32" | "f64" | "i32" | "u32" | "boolean" }; + +/** + * A call site of a lookup through an index: where its values go in the key's words, in order. A hash index takes the + * whole key; a sorted one a prefix, the first `prefixWords` words compared, and possibly a comparison on the next + * part, each operator's operand values and the words compared with them. + */ +export type WasmProbe = { + readonly values: readonly WasmKeyValue[]; + readonly prefixWords: number; + readonly comparisons: readonly { readonly operator: string; readonly values: readonly WasmKeyValue[]; readonly words: number }[] | null; +}; + +/** + * A lookup through an index: its structure, its key's words, what a hash index's bucket records or a sorted index's + * entries hold per entry, its probes, the leaves bodies read of its rows (read index and column), and how many + * components it watches. Its directory records are a match flag, the archetype's entities address, then each read's. + */ +export type WasmIndexLookup = { + readonly kind: "hash" | "sorted"; + readonly words: number; + readonly stride: number; + readonly probe: (probe: number) => WasmProbe | null; + readonly rowLeaf: (leaf: number) => { readonly read: number; readonly column: Omit } | null; + readonly watched: number; +}; + +/** The parameters each lookup through an index takes, after the lookup parameters, all as f64. */ +export const indexParameterCount = (lookup: WasmIndexLookup): number => lookup.kind === "hash" ? 7 + lookup.watched : 11 + lookup.watched; + +export type WasmKernel = { + /** Compile asynchronously: browsers limit synchronous compiles on the main thread. */ + readonly bytes: Uint8Array; + /** Which resource and args leaves the caller passes, after the column addresses. */ + readonly scalars: readonly WasmScalar[]; + /** The JS functions it imports, by name. */ + readonly imports: readonly string[]; + /** Whether it runs four rows at a time with SIMD, at storage precision. */ + readonly vectorized: boolean; +}; + +/** Math functions with exact native equivalents; the rest are imported from JS so results match bit for bit. */ +const native = new Set(["abs", "sqrt", "floor", "ceil", "trunc", "min", "max", "fround", "imul", "clz32"]); + +/** Imported operations that aren't `Math` functions. */ +const operations: { readonly [name: string]: (a: number, b: number) => number } = { + remainder: (a, b) => a % b, + power: (a, b) => a ** b, +}; + +/** Reads a scalar parameter's value from the resources and args of a run; booleans become 0 or 1. */ +export const scalarValue = (scalar: WasmScalar, resources: readonly unknown[], args: unknown): number => { + let value: unknown = scalar.source === "args" ? args : resources[scalar.index]; + for (const key of scalar.path) value = typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; + return Number(value); +}; + +/** Reads each scalar parameter's value from the resources and args of a run. */ +export const scalarValues = (scalars: readonly WasmScalar[], resources: readonly unknown[], args: unknown): number[] => + scalars.map(scalar => scalarValue(scalar, resources, args)); + +/** The `run` export of an instantiated kernel module: `lastRun` below 0 passes every row. */ +export type WasmRun = (start: number, end: number, tick: number, lastRun: number, ...values: number[]) => void; + +export const instantiateWasm = async (module: WebAssembly.Module, memory: WebAssembly.Memory, imports: readonly string[]): Promise => { + const instance = await WebAssembly.instantiate(module, { env: { memory }, math: importsOf(imports) }); + const run: unknown = instance.exports.run; + if (typeof run !== "function") throw new Error("A WASM kernel has no run export."); + // The generator always exports `run` with this signature. + return run as WasmRun; +}; + +/** The JS functions a kernel's imports name. */ +export const importsOf = (names: readonly string[]): { readonly [name: string]: (...values: number[]) => number } => { + const functions: { [name: string]: (...values: number[]) => number } = {}; + for (const name of names) { + const operation = operations[name]; + const math: unknown = Reflect.get(Math, name); + if (operation !== undefined) functions[name] = operation; + else if (typeof math === "function") functions[name] = (...values: number[]) => Number(Reflect.apply(math, Math, values)); + } + return functions; +}; + +// Opcodes. +const op = { + block: 0x02, loop: 0x03, if: 0x04, else: 0x05, end: 0x0b, br: 0x0c, brIf: 0x0d, call: 0x10, drop: 0x1a, select: 0x1b, + localGet: 0x20, localSet: 0x21, localTee: 0x22, + i32Load: 0x28, f32Load: 0x2a, f64Load: 0x2b, i32Load8u: 0x2d, i32Store: 0x36, f32Store: 0x38, f64Store: 0x39, i32Store8: 0x3a, + i32Const: 0x41, f64Const: 0x44, i32ReinterpretF32: 0xbc, i64ReinterpretF64: 0xbd, i32LtU: 0x49, i32GtU: 0x4b, i32LeU: 0x4d, i32GtS: 0x4a, f64Load2: 0x2b, + i32Eqz: 0x45, i32GeS: 0x4e, + f64Eq: 0x61, f64Ne: 0x62, f64Lt: 0x63, f64Gt: 0x64, f64Le: 0x65, f64Ge: 0x66, + i32Eq: 0x46, i32LtS: 0x48, i32LeS: 0x4c, i32Clz: 0x67, i32Add: 0x6a, i32Sub: 0x6b, i32Mul: 0x6c, i32And: 0x71, i32Or: 0x72, i32Xor: 0x73, i32Shl: 0x74, i32ShrS: 0x75, i32ShrU: 0x76, + f64Abs: 0x99, f64Neg: 0x9a, f64Ceil: 0x9b, f64Floor: 0x9c, f64Trunc: 0x9d, f64Sqrt: 0x9f, + f64Add: 0xa0, f64Sub: 0xa1, f64Mul: 0xa2, f64Div: 0xa3, f64Min: 0xa4, f64Max: 0xa5, + i32WrapI64: 0xa7, i32GeU: 0x4f, i32Ne: 0x47, i64Const: 0x42, i64ShrU: 0x88, i64RemS: 0x81, f64ConvertI64S: 0xb9, f64Copysign: 0xa6, f32DemoteF64: 0xb6, f64ConvertI32S: 0xb7, f64ConvertI32U: 0xb8, f64PromoteF32: 0xbb, +} as const; +const f64Type = 0x7c; +const i32Type = 0x7f; +/** Saturating truncations, 0xfc-prefixed opcodes. */ +const i64TruncSatF64S = [0xfc, 0x06]; +const truncations = { i32TruncSatF64U: [0xfc, 0x03], i64TruncSatF64U: [0xfc, 0x07] } as const; + +const unsigned = (value: number): number[] => { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value = Math.floor(value / 128); + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +}; + +const signed = (value: number): number[] => { + const bytes: number[] = []; + for (;;) { + const byte = value & 0x7f; + value >>= 7; + if ((value === 0 && (byte & 0x40) === 0) || (value === -1 && (byte & 0x40) !== 0)) return [...bytes, byte]; + bytes.push(byte | 0x80); + } +}; + +const float64 = (value: number): number[] => [...new Uint8Array(new Float64Array([value]).buffer)]; +const name = (text: string): number[] => { + const bytes = [...new TextEncoder().encode(text)]; + return [...unsigned(bytes.length), ...bytes]; +}; +const vector = (items: readonly (readonly number[])[]): number[] => [...unsigned(items.length), ...items.flat()]; +const section = (id: number, content: readonly number[]): number[] => [id, ...unsigned(content.length), ...content]; + + +/** Thrown inside generation when a program needs something WASM can't match exactly; the JS kernel is used instead. */ +const unsupported = Symbol("unsupported"); +/** Thrown when a program can't run four rows at a time; the scalar loop handles every row instead. */ +const notVectorizable = Symbol("notVectorizable"); + +const join = (a: Type, b: Type): Type => a === b ? a : "mixed"; + +/** The operators whose folds rotate through lanes: each one's identity, -0 for sums so a sum of -0 stays -0, and its instruction. */ +const rotated = { + "+": { value: -0, combine: op.f64Add }, + "*": { value: 1, combine: op.f64Mul }, + "min": { value: Infinity, combine: op.f64Min }, + "max": { value: -Infinity, combine: op.f64Max }, +} as const; + +/** + * Generates a WASM module for a kernel program, with JS semantics: every value is an f64, and stores + * convert as typed arrays do. Returns `null` when exact equivalence can't be guaranteed. + * + * The exported `run(start, end, tick, ...columnAddresses, ...scalars)` processes rows `start` to `end`. + */ +export const generateWasm = ( + source: Program, + columns: readonly WasmColumn[], + scalarTypes: (scalar: WasmScalar) => Type | null, + simdEnabled = true, + filters = 0, + lookups: readonly WasmLookup[] = [], + /** For a program reading `up`: the columns of the parent's components it reads. */ + upColumns: readonly WasmColumn[] = [], + /** Each accumulator's operator, in `reductions` order. */ + operators: readonly Reduction[] = [], + /** By lookup, for those through an index, how it reads its index; null for a lookup by entity. */ + indexLookups: readonly (WasmIndexLookup | null)[] = [], + /** By probe, its lookup: `KernelDescription.probes[k].lookup`. */ + probeLookups: readonly number[] = [], +): WasmKernel | null => { + const program = withoutIntrinsics(source); + const scalars: WasmScalar[] = []; + const scalarIndex = new Map(); + const imports: string[] = []; + const scalarOf = (scalar: WasmScalar): number => { + const key = JSON.stringify(scalar); + let index = scalarIndex.get(key); + if (index === undefined) { + if (scalarTypes(scalar) === null) throw unsupported; + index = scalars.length; + scalars.push(scalar); + scalarIndex.set(key, index); + } + return index; + }; + const importOf = (functionName: string): number => { + let index = imports.indexOf(functionName); + if (index < 0) { + index = imports.length; + imports.push(functionName); + } + return index; + }; + + /** Whether read `read` of lookup `lookup` is its `changed`, after what it reads. */ + const isChanged = (lookup: number, read: number): boolean => (lookups[lookup]?.watched ?? 0) > 0 && read === lookups[lookup]?.reads; + /** Whether any lookup watches, which then takes its `lastRun` after the other lookup parameters. */ + const watching = lookups.some(lookup => (lookup.watched ?? 0) > 0); + /** Each handle's lookup, from the `find`s into it; a handle used by two lookups isn't supported. */ + const lookupOfHandle = new Map(); + const claim = (handle: number, lookup: number): void => { + const existing = lookupOfHandle.get(handle); + if ((existing !== undefined && existing !== lookup) || lookup >= lookups.length) throw unsupported; + lookupOfHandle.set(handle, lookup); + }; + /** Each probe's lookup, by the probe's index. */ + const lookupOfProbe: ReadonlyMap = new Map(probeLookups.map((lookup, probe) => [probe, lookup])); + const collectFinds = (statements: readonly Statement[]): void => { + for (const statement of statements) { + if (statement.kind === "find") claim(statement.handle, statement.lookup); + else if (statement.kind === "probe") { + const lookup = lookupOfProbe.get(statement.probe); + if (lookup === undefined || indexLookups[lookup] === null || indexLookups[lookup] === undefined) throw unsupported; + claim(statement.list, lookup); + claim(statement.first, lookup); + } + else if (statement.kind === "listRow") { + const lookup = lookupOfHandle.get(statement.list); + if (lookup === undefined) throw unsupported; + claim(statement.row, lookup); + } + else if (statement.kind === "if") { collectFinds(statement.consequent); collectFinds(statement.alternate); } + else if (statement.kind === "block" || statement.kind === "loop") collectFinds(statement.body); + } + }; + + // Local types, to a fixed point over assignments. + const localTypes: (Type | undefined)[] = new Array(program.locals); + const typeOf = (expression: Expression): Type => { + switch (expression.kind) { + case "number": return "number"; + case "boolean": return "boolean"; + case "column": return columns[expression.column].boolean ? "boolean" : "number"; + case "resource": case "args": return scalarTypes(expression.kind === "args" ? { source: "args", path: expression.path } : { source: "resource", index: expression.index, path: expression.path }) ?? "mixed"; + case "local": return localTypes[expression.local] ?? "number"; + case "unary": return expression.operator === "!" ? "boolean" : "number"; + case "binary": return ["<", ">", "<=", ">=", "==", "!=", "===", "!=="].includes(expression.operator) ? "boolean" : "number"; + case "logical": return join(typeOf(expression.left), typeOf(expression.right)); + case "conditional": return join(typeOf(expression.consequent), typeOf(expression.alternate)); + case "math": return "number"; + case "found": case "upFound": return "boolean"; + case "upLeaf": return upColumns[expression.column]?.boolean === true ? "boolean" : "number"; + case "lookupLeaf": { + const lookup = lookupOfHandle.get(expression.handle); + if (lookup !== undefined && isChanged(lookup, expression.read)) return "boolean"; + const leaf = lookup === undefined ? null : lookups[lookup]?.leaf(expression.read, expression.path); + return leaf?.boolean === true ? "boolean" : "number"; + } + case "listLength": case "rowEntity": return "number"; + case "listChanged": return "boolean"; + case "rowLeaf": { + const lookup = lookupOfHandle.get(expression.handle); + const leaf = lookup === undefined ? null : indexLookups[lookup]?.rowLeaf(expression.leaf); + return leaf?.column.boolean === true ? "boolean" : "number"; + } + // Regions are inlined before generating. + case "lane": throw unsupported; + } + }; + const inferLocals = (statements: readonly Statement[]): boolean => { + let changed = false; + for (const statement of statements) { + if (statement.kind === "set") { + const type = typeOf(statement.value); + const current = localTypes[statement.local]; + const next = current === undefined ? type : join(current, type); + if (next !== current) { + localTypes[statement.local] = next; + changed = true; + } + } + else if (statement.kind === "if") changed = inferLocals(statement.consequent) || inferLocals(statement.alternate) || changed; + else if (statement.kind === "block" || statement.kind === "loop") changed = inferLocals(statement.body) || changed; + } + return changed; + }; + for (let pass = 0; pass < program.locals + 2 && inferLocals(program.body); pass++); + + /** Whether a value is an integer below 2^32 in magnitude: integer column reads, small integer literals, and locals of only those. */ + const accumulators = program.reductions ?? []; + // Accumulators start at their partials, which needn't be integers. + const integralLocals = new Array(program.locals).fill(true).map((integral, index) => integral && !accumulators.includes(index)); + const integral = (expression: Expression): boolean => { + switch (expression.kind) { + case "column": { + const { view, boolean } = columns[expression.column]; + return !boolean && (view === "i32" || view === "u32" || view === "u8"); + } + case "upLeaf": { + const column = upColumns[expression.column]; + return column !== undefined && !column.boolean && (column.view === "i32" || column.view === "u32" || column.view === "u8"); + } + case "number": return Number.isInteger(expression.value) && Math.abs(expression.value) <= 0x7FFFFFFF; + case "local": return integralLocals[expression.local]; + default: return false; + } + }; + const inferIntegral = (statements: readonly Statement[]): boolean => { + let changed = false; + for (const statement of statements) { + if (statement.kind === "set" && integralLocals[statement.local] && !integral(statement.value)) { + integralLocals[statement.local] = false; + changed = true; + } + else if (statement.kind === "if") changed = inferIntegral(statement.consequent) || inferIntegral(statement.alternate) || changed; + else if (statement.kind === "block" || statement.kind === "loop") changed = inferIntegral(statement.body) || changed; + } + return changed; + }; + while (inferIntegral(program.body)); + + // Parameters: start, end, tick, lastRun, column addresses, filter row tick addresses, filter chunk tick addresses, scalars, and with reductions the partials address. + const columnParameter = (column: number): number => 4 + column; + const filterRowParameter = (filter: number): number => 4 + columns.length + filter; + const filterChunkParameter = (filter: number): number => 4 + columns.length + filters + filter; + /** + * With lookups: the entity table (archetype, row and generation addresses, then its length), then each lookup's + * directory address and record count, all as f64 so workers pass them like scalars. + */ + const lookupParameters = lookups.length === 0 ? 0 : 4 + 2 * lookups.length + (watching ? 1 : 0); + const lookupParameter = (index: number): number => 4 + columns.length + 2 * filters + index; + /** For `up`: the rows' and their parents' rows' addresses, whether there's a parent, then each parent column's address. */ + const upMode = program.up === true; + const upParameters = upMode ? 3 + upColumns.length : 0; + /** Each lookup through an index's parameters, after the lookup parameters: `indexParameterCount` of them, as f64. */ + const indexOffsets: number[] = []; + let indexParameters = 0; + for (let lookup = 0; lookup < lookups.length; lookup++) { + indexOffsets.push(indexParameters); + const index = indexLookups[lookup]; + if (index !== null && index !== undefined) indexParameters += indexParameterCount(index); + } + const indexParameter = (lookup: number, index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexOffsets[lookup] + index; + const upParameter = (index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexParameters + index; + const scalarParameter = (index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexParameters + upParameters + index; + const code: number[] = []; + const extraLocals: number[] = []; + let localBase = 0; + const local = (index: number): number => localBase + index; + const scratch = (type: number): number => { + extraLocals.push(type); + return localBase + program.locals + extraLocals.length - 1; + }; + let rowLocal = 0; + /** For `up`: the current row's parent row. */ + let parentLocal = 0; + const address = (column: number): void => { + code.push(op.localGet, ...unsigned(columnParameter(column)), op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(columns[column].stride), op.i32Mul, op.i32Add); + }; + + /** Leaves the JS `ToInt32` of the f64 on the stack as an i32: exact for every finite value; NaN and infinities give 0. */ + const emitToInt32 = (): void => { + const value = scratch(f64Type); + code.push(op.localTee, ...unsigned(value)); + code.push(op.localGet, ...unsigned(value), op.f64Const, ...float64(2 ** -32), op.f64Mul, op.f64Trunc, op.f64Const, ...float64(2 ** 32), op.f64Mul, op.f64Sub); + code.push(...i64TruncSatF64S, op.i32WrapI64); + }; + + /** Leaves 1 (i32) if the f64 on the stack is truthy: not 0, -0 or NaN. */ + const emitTruthy = (type: Type): void => { + if (type === "boolean") { + code.push(op.f64Const, ...float64(0), op.f64Ne); + return; + } + const value = scratch(f64Type); + code.push(op.localTee, ...unsigned(value), op.f64Const, ...float64(0), op.f64Ne, op.localGet, ...unsigned(value), op.localGet, ...unsigned(value), op.f64Eq, op.i32And); + }; + + const emitCall = (functionName: string): void => { + code.push(op.call, ...unsigned(importOf(functionName))); + }; + + const emit = (expression: Expression): void => { + switch (expression.kind) { + case "number": code.push(op.f64Const, ...float64(expression.value)); return; + case "boolean": code.push(op.f64Const, ...float64(expression.value ? 1 : 0)); return; + case "local": code.push(op.localGet, ...unsigned(local(expression.local))); return; + case "resource": case "args": { + const scalar: WasmScalar = expression.kind === "args" ? { source: "args", path: expression.path } : { source: "resource", index: expression.index, path: expression.path }; + code.push(op.localGet, ...unsigned(scalarParameter(scalarOf(scalar)))); + return; + } + case "upFound": code.push(op.localGet, ...unsigned(upParameter(2)), op.f64ConvertI32U); return; + case "upLeaf": case "column": { + const { view, offset, boolean } = expression.kind === "upLeaf" ? upColumns[expression.column] : columns[expression.column]; + if (expression.kind === "upLeaf") { + code.push(op.localGet, ...unsigned(upParameter(3 + expression.column)), op.localGet, ...unsigned(parentLocal), op.i32Const, ...signed(upColumns[expression.column].stride), op.i32Mul, op.i32Add); + } + else address(expression.column); + const memory = (align: number): number[] => [align, ...unsigned(offset)]; + if (boolean) { + code.push(view === "u8" ? op.i32Load8u : op.i32Load, ...memory(view === "u8" ? 0 : 2), op.i32Const, 0, 0x47 /* i32.ne */, op.f64ConvertI32U); + return; + } + switch (view) { + case "f32": code.push(op.f32Load, ...memory(2), op.f64PromoteF32); return; + case "i32": code.push(op.i32Load, ...memory(2), op.f64ConvertI32S); return; + case "u32": code.push(op.i32Load, ...memory(2), op.f64ConvertI32U); return; + case "u8": code.push(op.i32Load8u, ...memory(0), op.f64ConvertI32U); return; + default: code.push(op.f64Load, ...memory(3)); return; + } + } + case "unary": + emit(expression.operand); + if (expression.operator === "-") code.push(op.f64Neg); + else if (expression.operator === "!") { + emitTruthy(typeOf(expression.operand)); + code.push(op.i32Eqz, op.f64ConvertI32U); + } + else if (expression.operator === "~") { + emitToInt32(); + code.push(op.i32Const, ...signed(-1), op.i32Xor, op.f64ConvertI32S); + } + return; + case "binary": return emitBinary(expression); + case "logical": { + const type = typeOf(expression.left); + const value = scratch(f64Type); + emit(expression.left); + code.push(op.localTee, ...unsigned(value)); + emitTruthy(type); + code.push(op.if, f64Type); + if (expression.operator === "&&") emit(expression.right); + else code.push(op.localGet, ...unsigned(value)); + code.push(op.else); + if (expression.operator === "&&") code.push(op.localGet, ...unsigned(value)); + else emit(expression.right); + code.push(op.end); + return; + } + case "conditional": + emit(expression.test); + emitTruthy(typeOf(expression.test)); + code.push(op.if, f64Type); + emit(expression.consequent); + code.push(op.else); + emit(expression.alternate); + code.push(op.end); + return; + case "math": return emitMath(expression.name, expression.arguments); + case "found": + code.push(op.localGet, ...unsigned(handleLocal(expression.handle, "found")), op.f64ConvertI32U); + return; + case "lookupLeaf": return emitLookupLeaf(expression.handle, expression.read, expression.path); + case "listLength": return emitListLength(expression.handle); + case "listChanged": return emitListChanged(expression.handle); + case "rowLeaf": return emitRowLeaf(expression.handle, expression.leaf); + case "rowEntity": return emitRowEntity(expression.handle); + } + }; + + const emitMath = (functionName: string, parameters: readonly Expression[]): void => { + if (!native.has(functionName)) { + // Imported functions take two f64s; unary ones ignore the second. + if (parameters.length > 2) throw unsupported; + for (const parameter of parameters) emit(parameter); + for (let i = parameters.length; i < 2; i++) code.push(op.f64Const, ...float64(NaN)); + emitCall(functionName); + return; + } + switch (functionName) { + case "min": case "max": { + if (parameters.length === 0) { + code.push(op.f64Const, ...float64(functionName === "min" ? Infinity : -Infinity)); + return; + } + emit(parameters[0]); + // JS converts every argument, so `Math.max(x)` is `+x`. + for (const parameter of parameters.slice(1)) { + emit(parameter); + code.push(functionName === "min" ? op.f64Min : op.f64Max); + } + return; + } + case "imul": + emit(parameters[0] ?? { kind: "number", value: 0 }); + emitToInt32(); + emit(parameters[1] ?? { kind: "number", value: 0 }); + emitToInt32(); + code.push(op.i32Mul, op.f64ConvertI32S); + return; + case "clz32": + emit(parameters[0] ?? { kind: "number", value: 0 }); + emitToInt32(); + code.push(op.i32Clz, op.f64ConvertI32U); + return; + case "fround": + emit(parameters[0] ?? { kind: "number", value: NaN }); + code.push(op.f32DemoteF64, op.f64PromoteF32); + return; + default: { + emit(parameters[0] ?? { kind: "number", value: NaN }); + const unary = { abs: op.f64Abs, sqrt: op.f64Sqrt, floor: op.f64Floor, ceil: op.f64Ceil, trunc: op.f64Trunc }[functionName]; + if (unary === undefined) throw unsupported; + code.push(unary); + } + } + }; + + const comparisons = { "<": op.f64Lt, ">": op.f64Gt, "<=": op.f64Le, ">=": op.f64Ge, "==": op.f64Eq, "!=": op.f64Ne, "===": op.f64Eq, "!==": op.f64Ne } as const; + const arithmetic = { "+": op.f64Add, "-": op.f64Sub, "*": op.f64Mul, "/": op.f64Div } as const; + const bitwise = { "&": op.i32And, "|": op.i32Or, "^": op.i32Xor, "<<": op.i32Shl, ">>": op.i32ShrS, ">>>": op.i32ShrU } as const; + + const emitBinary = (expression: Extract): void => { + const { operator, left, right } = expression; + if (operator === "===" || operator === "!==") { + const leftType = typeOf(left); + const rightType = typeOf(right); + if (leftType === "mixed" || rightType === "mixed") throw unsupported; + // A boolean never strictly equals a number. Operands are pure, so they needn't be evaluated. + if (leftType !== rightType) { + code.push(op.f64Const, ...float64(operator === "===" ? 0 : 1)); + return; + } + } + // Integers by a nonzero literal: i64 remainder is exact, and copysign gives JS's -0 for a negative dividend. + if (operator === "%" && integral(left) && right.kind === "number" && right.value !== 0 && integral(right)) { + const dividend = scratch(f64Type); + emit(left); + code.push(op.localTee, ...unsigned(dividend), ...i64TruncSatF64S, op.i64Const, ...signed(right.value), op.i64RemS, op.f64ConvertI64S, op.localGet, ...unsigned(dividend), op.f64Copysign); + return; + } + if (operator === "%" || operator === "**") { + emit(left); + emit(right); + emitCall(operator === "%" ? "remainder" : "power"); + return; + } + emit(left); + if (operator in bitwise) { + emitToInt32(); + emit(right); + emitToInt32(); + code.push(bitwise[operator as keyof typeof bitwise]); + code.push(operator === ">>>" ? op.f64ConvertI32U : op.f64ConvertI32S); + return; + } + emit(right); + if (operator in arithmetic) { + code.push(arithmetic[operator as keyof typeof arithmetic]); + return; + } + code.push(comparisons[operator as keyof typeof comparisons], op.f64ConvertI32U); + }; + + /** Labels from innermost: program blocks and loops count toward IR depths; `if`s and the row block don't. */ + const labels: ("program" | "if" | "next")[] = []; + const wasmDepth = (depth: number): number => { + let seen = -1; + for (let i = labels.length - 1; i >= 0; i--) { + if (labels[i] === "program" && ++seen === depth) return labels.length - 1 - i; + } + throw unsupported; + }; + + const store = (column: number, value: Expression | "tick"): void => { + const { view, offset } = columns[column]; + address(column); + if (value === "tick") code.push(op.localGet, 2); + else emit(value); + const memory = (align: number): number[] => [align, ...unsigned(offset)]; + switch (view) { + case "f32": code.push(op.f32DemoteF64, op.f32Store, ...memory(2)); return; + case "i32": case "u32": emitToInt32(); code.push(op.i32Store, ...memory(2)); return; + case "u8": emitToInt32(); code.push(op.i32Store8, ...memory(0)); return; + default: code.push(op.f64Store, ...memory(3)); return; + } + }; + + /** + * Chunk columns stamped by every row: top-level stamps with no `next` before them, in an unfiltered kernel. + * Their chunks are stamped once per call, for the whole range, instead of on each row. + */ + const everyRow = new Set(); + // Listed rows needn't be consecutive, so each stamps its own chunk. + if (filters === 0 && !upMode) { + const ends = (statement: Statement): boolean => + statement.kind === "next" || (statement.kind === "if" && (statement.consequent.some(ends) || statement.alternate.some(ends))) + || ((statement.kind === "block" || statement.kind === "loop") && statement.body.some(ends)); + for (const statement of program.body) { + if (ends(statement)) break; + if (statement.kind === "stamp" && columns[statement.chunks].muted !== true) everyRow.add(statement.chunks); + } + } + /** Stamps every chunk from `start`'s to `end - 1`'s, for the chunk columns every row stamps. */ + const stampRangeChunks = (): void => { + for (const chunks of everyRow) { + const chunk = scratch(i32Type); + const last = scratch(i32Type); + // if (start < end) for (chunk = start >> shift; chunk <= (end - 1) >> shift; chunk++) chunks[chunk] = tick + code.push(op.localGet, 0, op.localGet, 1, op.i32LtS, op.if, 0x40); + code.push(op.localGet, 0, op.i32Const, ...signed(chunkShift), op.i32ShrU, op.localSet, ...unsigned(chunk)); + code.push(op.localGet, 1, op.i32Const, 1, op.i32Sub, op.i32Const, ...signed(chunkShift), op.i32ShrU, op.localSet, ...unsigned(last)); + code.push(op.loop, 0x40); + code.push(op.localGet, ...unsigned(columnParameter(chunks)), op.localGet, ...unsigned(chunk), op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); + code.push(op.localGet, ...unsigned(chunk), op.i32Const, 1, op.i32Add, op.localTee, ...unsigned(chunk), op.localGet, ...unsigned(last), op.i32LeS, op.brIf, 0); + code.push(op.end, op.end); + } + }; + + // ---- Lookups: a find checks the entity table and the lookup's directory record; leaves read through the record. ---- + + /** i32 locals holding the lookup parameters, converted once per call. */ + let lookupLocals: number[] = []; + const convertLookupParameters = (): void => { + lookupLocals = []; + // The `lastRun` lookups watching compare with stays an f64 parameter. + for (let index = 0; index < lookupParameters - (watching ? 1 : 0); index++) { + const target = scratch(i32Type); + code.push(op.localGet, ...unsigned(lookupParameter(index)), ...truncations.i32TruncSatF64U, op.localSet, ...unsigned(target)); + lookupLocals.push(target); + } + }; + const entityArchetypes = (): number => lookupLocals[0]; + const entityRows = (): number => lookupLocals[1]; + const entityGenerations = (): number => lookupLocals[2]; + const entityCount = (): number => lookupLocals[3]; + const directory = (lookup: number): number => lookupLocals[4 + 2 * lookup]; + const directoryCount = (lookup: number): number => lookupLocals[5 + 2 * lookup]; + + /** Per handle: whether found, the row, and the address of its directory record. */ + const handleLocals = new Map(); + const handleLocal = (handle: number, part: "found" | "row" | "record"): number => { + const key = `${handle}:${part}`; + let index = handleLocals.get(key); + if (index === undefined) handleLocals.set(key, index = scratch(i32Type)); + return index; + }; + + const emitFind = (handle: number, lookup: number, entity: Expression): void => { + const found = handleLocal(handle, "found"); + const row = handleLocal(handle, "row"); + const record = handleLocal(handle, "record"); + const value = scratch(f64Type); + const wide = scratch(0x7e); + const generation = scratch(f64Type); + const index = scratch(i32Type); + const archetype = scratch(i32Type); + const words = 1 + lookups[lookup].reads + (lookups[lookup].watched ?? 0); + code.push(op.i32Const, 0, op.localSet, ...unsigned(found)); + // As JS `locate`: the index is ToUint32 of the entity and the generation is floor(entity / 2^32), compared as + // numbers, so negative, fractional and NaN entities behave exactly as they do there. + emit(entity); + code.push(op.localTee, ...unsigned(value)); + // Real entities are non-negative integers: one i64 gives both parts. Anything else takes the general path. + code.push(...i64TruncSatF64S, op.localTee, ...unsigned(wide), op.f64ConvertI64S, op.localGet, ...unsigned(value), op.f64Eq); + code.push(op.localGet, ...unsigned(value), op.f64Const, ...float64(0), op.f64Ge, op.i32And); + code.push(op.if, 0x40); + code.push(op.localGet, ...unsigned(wide), op.i32WrapI64, op.localSet, ...unsigned(index)); + code.push(op.localGet, ...unsigned(wide), op.i64Const, 32, op.i64ShrU, op.f64ConvertI64S, op.localSet, ...unsigned(generation)); + code.push(op.else); + code.push(op.localGet, ...unsigned(value)); + emitToInt32(); + code.push(op.localSet, ...unsigned(index)); + code.push(op.localGet, ...unsigned(value), op.f64Const, ...float64(2 ** -32), op.f64Mul, op.f64Floor, op.localSet, ...unsigned(generation)); + code.push(op.end); + code.push(op.block, 0x40); + labels.push("if"); + code.push(op.localGet, ...unsigned(index), op.localGet, ...unsigned(entityCount()), op.i32GeU, op.brIf, 0); + code.push(op.localGet, ...unsigned(entityGenerations()), op.localGet, ...unsigned(index), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.f64ConvertI32U); + code.push(op.localGet, ...unsigned(generation), op.f64Ne, op.brIf, 0); + code.push(op.localGet, ...unsigned(entityArchetypes()), op.localGet, ...unsigned(index), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localTee, ...unsigned(archetype)); + code.push(op.i32Const, 0, op.i32LtS, op.brIf, 0); + code.push(op.localGet, ...unsigned(archetype), op.localGet, ...unsigned(directoryCount(lookup)), op.i32GeS, op.brIf, 0); + code.push(op.localGet, ...unsigned(directory(lookup)), op.localGet, ...unsigned(archetype), op.i32Const, ...signed(4 * words), op.i32Mul, op.i32Add, op.localTee, ...unsigned(record)); + code.push(op.i32Load, 2, 0, op.i32Eqz, op.brIf, 0); + code.push(op.localGet, ...unsigned(entityRows()), op.localGet, ...unsigned(index), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localSet, ...unsigned(row)); + code.push(op.i32Const, 1, op.localSet, ...unsigned(found)); + labels.pop(); + code.push(op.end); + }; + + /** + * A found entity's `changed`: whether a watched component's tick at its row is newer than the lookups' `lastRun`, + * or one keeps no ticks, as 0 or 1; NaN if nothing was found. + */ + const emitChanged = (handle: number, lookup: number): void => { + const { reads, watched = 0 } = lookups[lookup]; + const since = lookupParameter(lookupParameters - 1); + const changed = scratch(i32Type); + const address = scratch(i32Type); + code.push(op.localGet, ...unsigned(handleLocal(handle, "found")), op.if, f64Type); + labels.push("if"); + code.push(op.i32Const, 0, op.localSet, ...unsigned(changed)); + for (let w = 0; w < watched; w++) { + code.push(op.localGet, ...unsigned(handleLocal(handle, "record")), op.i32Load, 2, ...unsigned(4 * (1 + reads + w)), op.localTee, ...unsigned(address)); + code.push(op.i32Eqz, op.if, i32Type); + labels.push("if"); + code.push(op.i32Const, 1); + code.push(op.else); + code.push(op.localGet, ...unsigned(address), op.localGet, ...unsigned(handleLocal(handle, "row")), op.i32Const, 3, op.i32Shl, op.i32Add, op.f64Load, 3, 0); + code.push(op.localGet, ...unsigned(since), op.f64Gt); + code.push(op.end); + labels.pop(); + code.push(op.localGet, ...unsigned(changed), op.i32Or, op.localSet, ...unsigned(changed)); + } + code.push(op.localGet, ...unsigned(changed), op.f64ConvertI32U); + code.push(op.else, op.f64Const, ...float64(NaN), op.end); + labels.pop(); + }; + + const emitLookupLeaf = (handle: number, read: number, path: readonly (number | string)[]): void => { + const lookup = lookupOfHandle.get(handle); + if (lookup !== undefined && isChanged(lookup, read)) { + if (path.length > 0) throw unsupported; + emitChanged(handle, lookup); + return; + } + const leaf = lookup === undefined ? null : lookups[lookup].leaf(read, path); + if (leaf === null) throw unsupported; + const { view, stride, offset, boolean } = leaf; + code.push(op.localGet, ...unsigned(handleLocal(handle, "found")), op.if, f64Type); + labels.push("if"); + // The column's address from the record, then the row's element. + code.push(op.localGet, ...unsigned(handleLocal(handle, "record")), op.i32Load, 2, ...unsigned(4 * (1 + read))); + code.push(op.localGet, ...unsigned(handleLocal(handle, "row")), op.i32Const, ...signed(stride), op.i32Mul, op.i32Add); + const memory = (align: number): number[] => [align, ...unsigned(offset)]; + if (boolean) code.push(view === "u8" ? op.i32Load8u : op.i32Load, ...memory(view === "u8" ? 0 : 2), op.i32Const, 0, op.i32Ne, op.f64ConvertI32U); + else if (view === "f32") code.push(op.f32Load, ...memory(2), op.f64PromoteF32); + else if (view === "i32") code.push(op.i32Load, ...memory(2), op.f64ConvertI32S); + else if (view === "u32") code.push(op.i32Load, ...memory(2), op.f64ConvertI32U); + else if (view === "u8") code.push(op.i32Load8u, ...memory(0), op.f64ConvertI32U); + else code.push(op.f64Load, ...memory(3)); + code.push(op.else, op.f64Const, ...float64(NaN), op.end); + labels.pop(); + }; + + // ---- Lookups through indexes: probes of a hash table or searches of a sorted index's blocks, and their lists' rows. ---- + + const get = (index: number): number[] => [op.localGet, ...unsigned(index)]; + const put = (index: number): number[] => [op.localSet, ...unsigned(index)]; + const tee = (index: number): number[] => [op.localTee, ...unsigned(index)]; + const constant = (value: number): number[] => [op.i32Const, ...signed(value | 0)]; + const load = (offset: number): number[] => [op.i32Load, 2, ...unsigned(offset)]; + + /** By lookup through an index, its parameters as locals: addresses and counts as i32, ticks as f64. */ + let indexLocals: number[][] = []; + /** Which of a hash index's and a sorted index's parameters are f64. */ + const hashTicks = new Set([5, 6]); + const sortedTicks = new Set([9, 10]); + const convertIndexParameters = (): void => { + indexLocals = []; + for (let lookup = 0; lookup < lookups.length; lookup++) { + const index = indexLookups[lookup]; + const locals: number[] = []; + indexLocals.push(locals); + if (index === null || index === undefined) continue; + const ticks = index.kind === "hash" ? hashTicks : sortedTicks; + for (let k = 0; k < indexParameterCount(index); k++) { + const f64 = ticks.has(k); + const target = scratch(f64 ? f64Type : i32Type); + code.push(...get(indexParameter(lookup, k))); + if (!f64) code.push(...truncations.i32TruncSatF64U); + code.push(...put(target)); + locals.push(target); + } + } + }; + const indexLocal = (lookup: number, k: number): number => indexLocals[lookup][k]; + /** The lookup a handle is of, and its index's description. */ + const indexOf = (handle: number): { readonly lookup: number; readonly index: WasmIndexLookup } => { + const lookup = lookupOfHandle.get(handle); + const index = lookup === undefined ? undefined : indexLookups[lookup]; + if (lookup === undefined || index === null || index === undefined) throw unsupported; + return { lookup, index }; + }; + /** A list handle's i32 locals: a hash list's start, length and bucket; a sorted list's runs and cursor. */ + const listLocal = (handle: number, part: "start" | "length" | "bucket" | "low" | "high" | "second" | "end" | "k" | "first" | "count"): number => { + const key = `${handle}:list:${part}`; + let index = handleLocals.get(key); + if (index === undefined) handleLocals.set(key, index = scratch(i32Type)); + return index; + }; + /** The key's words of a lookup's probes, as i32 locals. */ + const keyLocals = new Map(); + const keyLocal = (lookup: number, word: number): number => { + const key = `${lookup}:${word}`; + let index = keyLocals.get(key); + if (index === undefined) keyLocals.set(key, index = scratch(i32Type)); + return index; + }; + + /** Encodes the f64 on the stack, converted to `value`'s stored type as a store converts it, into the key's words where it goes. */ + const encodeKey = (lookup: number, value: WasmKeyValue): void => { + const word = keyLocal(lookup, value.word); + switch (value.scalar) { + case "i32": + emitToInt32(); + code.push(...constant(0x80000000), op.i32Xor, ...put(word)); + return; + case "u32": + emitToInt32(); + code.push(...put(word)); + return; + case "boolean": + emitToInt32(); + code.push(...constant(0xFF), op.i32And, ...constant(0), op.i32Ne, ...put(word)); + return; + case "f32": { + const bits = scratch(i32Type); + code.push(op.f32DemoteF64, op.i32ReinterpretF32, ...tee(bits)); + // NaN last, -0 as +0, a negative's bits flipped, a positive's sign set. + code.push(...constant(0x7F800000), op.i32And, ...constant(0x7F800000), op.i32Eq); + code.push(...get(bits), ...constant(0x7FFFFF), op.i32And, ...constant(0), op.i32Ne, op.i32And); + code.push(op.if, i32Type, ...constant(-1), op.else); + code.push(...get(bits), ...constant(0x80000000), op.i32Eq, op.if, i32Type, ...constant(0x80000000), op.else); + code.push(...get(bits), ...constant(31), op.i32ShrU, op.if, i32Type, ...get(bits), ...constant(-1), op.i32Xor, op.else, ...get(bits), ...constant(0x80000000), op.i32Or, op.end); + code.push(op.end, op.end, ...put(word)); + return; + } + default: { + const wide = scratch(0x7e); + const high = scratch(i32Type); + const low = scratch(i32Type); + const next = keyLocal(lookup, value.word + 1); + code.push(op.i64ReinterpretF64, ...tee(wide), op.i64Const, 32, op.i64ShrU, op.i32WrapI64, ...put(high)); + code.push(...get(wide), op.i32WrapI64, ...put(low)); + code.push(...get(high), ...constant(0x7FF00000), op.i32And, ...constant(0x7FF00000), op.i32Eq); + code.push(...get(high), ...constant(0xFFFFF), op.i32And, ...get(low), op.i32Or, ...constant(0), op.i32Ne, op.i32And); + code.push(op.if, 0x40, ...constant(-1), ...put(word), ...constant(-1), ...put(next), op.else); + code.push(...get(high), ...constant(0x80000000), op.i32Eq, ...get(low), op.i32Eqz, op.i32And); + code.push(op.if, 0x40, ...constant(0x80000000), ...put(word), ...constant(0), ...put(next), op.else); + code.push(...get(high), ...constant(31), op.i32ShrU); + code.push(op.if, 0x40, ...get(high), ...constant(-1), op.i32Xor, ...put(word), ...get(low), ...constant(-1), op.i32Xor, ...put(next)); + code.push(op.else, ...get(high), ...constant(0x80000000), op.i32Or, ...put(word), ...get(low), ...put(next), op.end); + code.push(op.end, op.end); + return; + } + } + }; + + /** Points row handle `row` at an entry's archetype and row, found when its archetype holds what the lookup reads. */ + const bindRow = (row: number, lookup: number, archetype: number, entryRow: number): void => { + const found = handleLocal(row, "found"); + const record = handleLocal(row, "record"); + const words = 1 + lookups[lookup].reads; + code.push(...constant(0), ...put(found)); + code.push(op.block, 0x40); + code.push(...get(archetype), ...get(directoryCount(lookup)), op.i32GeU, op.brIf, 0); + code.push(...get(directory(lookup)), ...get(archetype), ...constant(4 * words), op.i32Mul, op.i32Add, ...tee(record)); + code.push(...load(0), op.i32Eqz, op.brIf, 0); + code.push(...get(entryRow), ...put(handleLocal(row, "row")), ...constant(1), ...put(found)); + code.push(op.end); + }; + + /** Fenwick tree sum of the lengths of the blocks before position `k` (an i32 local), left on the stack. */ + const emitPrefix = (lookup: number, k: number): void => { + const node = scratch(i32Type); + const sum = scratch(i32Type); + const counts = indexLocal(lookup, 5); + code.push(...constant(0), ...put(sum), ...get(k), ...put(node)); + code.push(op.block, 0x40, op.loop, 0x40); + code.push(...get(node), op.i32Eqz, op.brIf, 1); + code.push(...get(sum), ...get(counts), ...get(node), ...constant(2), op.i32Shl, op.i32Add, ...load(0), op.i32Add, ...put(sum)); + code.push(...get(node), ...get(node), ...constant(0), ...get(node), op.i32Sub, op.i32And, op.i32Sub, ...put(node), op.br, 0); + code.push(op.end, op.end, ...get(sum)); + }; + + /** The position in `order` of the block holding entry position `position` (an i32 local), into `k`. */ + const emitBlockAt = (lookup: number, position: number, k: number): void => { + const remaining = scratch(i32Type); + const step = scratch(i32Type); + const next = scratch(i32Type); + const blocks = indexLocal(lookup, 1); + const counts = indexLocal(lookup, 5); + code.push(...constant(0), ...put(k), ...get(position), ...put(remaining)); + // The largest power of two at most `blocks`, or 0 without blocks. + code.push(...get(blocks), op.i32Eqz, op.if, i32Type, ...constant(0), op.else, ...constant(1), ...constant(31), ...get(blocks), op.i32Clz, op.i32Sub, op.i32Shl, op.end, ...put(step)); + code.push(op.block, 0x40, op.loop, 0x40); + code.push(...get(step), op.i32Eqz, op.brIf, 1); + code.push(...get(k), ...get(step), op.i32Add, ...tee(next), ...get(blocks), op.i32LeU); + code.push(op.if, 0x40); + code.push(...get(counts), ...get(next), ...constant(2), op.i32Shl, op.i32Add, ...load(0), ...get(remaining), op.i32LeU); + code.push(op.if, 0x40, ...get(next), ...put(k), ...get(remaining), ...get(counts), ...get(next), ...constant(2), op.i32Shl, op.i32Add, ...load(0), op.i32Sub, ...put(remaining), op.end); + code.push(op.end); + code.push(...get(step), ...constant(1), op.i32ShrU, ...put(step), op.br, 0); + code.push(op.end, op.end); + }; + + /** + * Leaves 1 when the key at `address` (an i32 local) passes against the key's first `count` words: after them, or + * with `after` false, not before them, comparing unsigned words in order. + */ + const emitPasses = (lookup: number, address: number, count: number, after: boolean): void => { + const passes = scratch(i32Type); + const word = scratch(i32Type); + code.push(...constant(after ? 0 : 1), ...put(passes)); + code.push(op.block, 0x40); + for (let w = 0; w < count; w++) { + code.push(...get(address), ...load(4 * w), ...tee(word), ...get(keyLocal(lookup, w)), op.i32Ne); + code.push(op.if, 0x40, ...get(word), ...get(keyLocal(lookup, w)), op.i32GtU, ...put(passes), op.br, 1, op.end); + } + code.push(op.end, ...get(passes)); + }; + + /** + * The first entry position whose key's first `count` words pass, into `result`: a search over the blocks' fences, + * then within a block. A lower bound leaves the list's cursor at its block. + */ + const emitBound = (lookup: number, index: WasmIndexLookup, count: number, after: boolean, result: number, list: number): void => { + if (count === 0) { + code.push(...(after ? get(indexLocal(lookup, 6)) : constant(0)), ...put(result)); + return; + } + const low = scratch(i32Type); + const high = scratch(i32Type); + const middle = scratch(i32Type); + const address = scratch(i32Type); + const block = scratch(i32Type); + const base = scratch(i32Type); + const fences = indexLocal(lookup, 0); + const blocks = indexLocal(lookup, 1); + const fenceBytes = 4 * (index.words + 1); + const entryBytes = 4 * index.stride; + const search = (from: () => void, length: () => void, at: () => void): void => { + code.push(...constant(0), ...put(low)); + length(); + code.push(...put(high)); + code.push(op.block, 0x40, op.loop, 0x40); + code.push(...get(low), ...get(high), op.i32GeU, op.brIf, 1); + code.push(...get(low), ...get(high), op.i32Add, ...constant(1), op.i32ShrU, ...put(middle)); + at(); + code.push(...put(address)); + emitPasses(lookup, address, count, after); + code.push(op.if, 0x40, ...get(middle), ...put(high), op.else, ...get(middle), ...constant(1), op.i32Add, ...put(low), op.end); + code.push(op.br, 0, op.end, op.end); + void from; + }; + search(() => {}, () => code.push(...get(blocks)), () => code.push(...get(fences), ...get(middle), ...constant(fenceBytes), op.i32Mul, op.i32Add)); + // Past the last block: the size. + code.push(...get(low), ...get(blocks), op.i32GeU, op.if, 0x40, ...get(indexLocal(lookup, 6)), ...put(result), op.else); + const k = scratch(i32Type); + code.push(...get(low), ...put(k)); + code.push(...get(indexLocal(lookup, 2)), ...get(k), ...constant(2), op.i32Shl, op.i32Add, ...load(0), ...put(block)); + code.push(...get(indexLocal(lookup, 4)), ...get(block), ...constant(entryBytes * 128), op.i32Mul, op.i32Add, ...put(base)); + search(() => {}, () => code.push(...get(indexLocal(lookup, 3)), ...get(block), ...constant(2), op.i32Shl, op.i32Add, ...load(0)), + () => code.push(...get(base), ...get(middle), ...constant(entryBytes), op.i32Mul, op.i32Add)); + emitPrefix(lookup, k); + if (!after) { + const first = scratch(i32Type); + code.push(...tee(first), ...get(low), op.i32Add, ...put(result)); + code.push(...get(k), ...put(listLocal(list, "k")), ...get(first), ...put(listLocal(list, "first"))); + code.push(...get(indexLocal(lookup, 3)), ...get(block), ...constant(2), op.i32Shl, op.i32Add, ...load(0), ...put(listLocal(list, "count"))); + } + else code.push(...get(low), op.i32Add, ...put(result)); + code.push(op.end); + }; + + /** Looks a key up, into list handle `list`, and its first row into `first`. */ + const emitProbe = (list: number, first: number, probe: number, values: readonly Expression[]): void => { + const { lookup, index } = indexOf(list); + const plan = index.probe(probe); + if (plan === null) throw unsupported; + let next = 0; + const encode = (targets: readonly WasmKeyValue[]): void => { + for (const target of targets) { + const value = values[next++]; + if (value === undefined) throw unsupported; + emit(value); + encodeKey(lookup, target); + } + }; + encode(plan.values); + if (index.kind === "hash") { + const hash = scratch(i32Type); + const slot = scratch(i32Type); + const address = scratch(i32Type); + const filled = scratch(i32Type); + const base = scratch(i32Type); + const bucket = listLocal(list, "bucket"); + const slots = indexLocal(lookup, 0); + const mask = indexLocal(lookup, 1); + const records = indexLocal(lookup, 2); + const recordBytes = 4 * index.stride; + code.push(...constant(0x811C9DC5), ...put(hash)); + for (let w = 0; w < index.words; w++) { + code.push(...get(hash), ...get(keyLocal(lookup, w)), op.i32Xor, ...constant(0x9E3779B1), op.i32Mul, ...tee(hash), ...get(hash), ...constant(15), op.i32ShrU, op.i32Xor, ...put(hash)); + } + code.push(...get(hash), ...get(hash), ...constant(16), op.i32ShrU, op.i32Xor, ...constant(0x85EBCA6B), op.i32Mul, ...put(hash)); + code.push(...get(hash), ...get(hash), ...constant(13), op.i32ShrU, op.i32Xor, ...constant(0xC2B2AE35), op.i32Mul, ...put(hash)); + code.push(...get(hash), ...get(hash), ...constant(16), op.i32ShrU, op.i32Xor, ...put(hash)); + code.push(...get(hash), ...get(mask), op.i32And, ...put(slot), ...constant(-1), ...put(bucket)); + code.push(op.block, 0x40, op.loop, 0x40); + code.push(...get(slots), ...get(slot), ...constant(8), op.i32Mul, op.i32Add, ...tee(address), ...load(4), ...tee(filled), op.i32Eqz, op.brIf, 1); + code.push(...get(address), ...load(0), ...get(hash), op.i32Eq, op.if, 0x40); + code.push(...get(records), ...get(filled), ...constant(1), op.i32Sub, ...constant(recordBytes), op.i32Mul, op.i32Add, ...put(base)); + code.push(...constant(1)); + for (let w = 0; w < index.words; w++) code.push(...get(base), ...load(16 + 4 * w), ...get(keyLocal(lookup, w)), op.i32Eq, op.i32And); + code.push(op.if, 0x40, ...get(filled), ...constant(1), op.i32Sub, ...put(bucket), op.br, 3, op.end); + code.push(op.end); + code.push(...get(slot), ...constant(1), op.i32Add, ...get(mask), op.i32And, ...put(slot), op.br, 0); + code.push(op.end, op.end); + code.push(...get(bucket), ...constant(0), op.i32LtS, op.if, 0x40, ...constant(0), ...put(listLocal(list, "start")), ...constant(0), ...put(listLocal(list, "length"))); + code.push(op.else); + code.push(...get(records), ...get(bucket), ...constant(recordBytes), op.i32Mul, op.i32Add, ...tee(base), ...load(4), ...put(listLocal(list, "start"))); + code.push(...get(base), ...load(8), ...put(listLocal(list, "length"))); + code.push(op.end); + } + else { + const low = listLocal(list, "low"); + const high = listLocal(list, "high"); + const second = listLocal(list, "second"); + const end = listLocal(list, "end"); + code.push(...constant(0), ...put(second), ...constant(0), ...put(end), ...constant(0), ...put(listLocal(list, "count"))); + if (plan.comparisons === null) { + emitBound(lookup, index, plan.prefixWords, false, low, list); + emitBound(lookup, index, plan.prefixWords, true, high, list); + } + else { + emitBound(lookup, index, plan.prefixWords, false, low, list); + emitBound(lookup, index, plan.prefixWords, true, high, list); + const bound = scratch(i32Type); + const unequalLow = scratch(i32Type); + const unequalHigh = scratch(i32Type); + code.push(...constant(-1), ...put(unequalLow)); + const raise = (): void => { code.push(...get(bound), ...get(low), ...get(bound), ...get(low), op.i32GtS, op.select, ...put(low)); }; + const lower = (): void => { code.push(...get(bound), ...get(high), ...get(bound), ...get(high), op.i32LtS, op.select, ...put(high)); }; + for (const comparison of plan.comparisons) { + encode(comparison.values); + switch (comparison.operator) { + case ">=": emitBound(lookup, index, comparison.words, false, bound, list); raise(); break; + case ">": emitBound(lookup, index, comparison.words, true, bound, list); raise(); break; + case "<": emitBound(lookup, index, comparison.words, false, bound, list); lower(); break; + case "<=": emitBound(lookup, index, comparison.words, true, bound, list); lower(); break; + case "==": + emitBound(lookup, index, comparison.words, false, bound, list); raise(); + emitBound(lookup, index, comparison.words, true, bound, list); lower(); + break; + case "!=": + emitBound(lookup, index, comparison.words, false, unequalLow, list); + emitBound(lookup, index, comparison.words, true, unequalHigh, list); + break; + default: throw unsupported; + } + } + // An empty range, and without `!=`'s, one run; with it, the rows before and after the value. + code.push(...get(high), ...get(low), op.i32LtS, op.if, 0x40, ...get(low), ...put(high), op.end); + code.push(...get(unequalLow), ...constant(0), op.i32GeS, op.if, 0x40); + // second = max(low, min(high, unequalHigh)); end = high; high = min(high, max(low, unequalLow)) + code.push(...get(unequalHigh), ...get(high), ...get(unequalHigh), ...get(high), op.i32LtS, op.select, ...put(second)); + code.push(...get(second), ...get(low), ...get(second), ...get(low), op.i32GtS, op.select, ...put(second)); + code.push(...get(high), ...put(end)); + code.push(...get(unequalLow), ...get(low), ...get(unequalLow), ...get(low), op.i32GtS, op.select, ...put(bound)); + code.push(...get(bound), ...get(high), ...get(bound), ...get(high), op.i32LtS, op.select, ...put(high)); + code.push(...get(second), ...get(end), op.i32GeS, op.if, 0x40, ...constant(0), ...put(second), ...constant(0), ...put(end), op.end); + code.push(op.end); + } + // The cursor holds the block of the last lower bound, which needn't be the first row's: `count` 0 marks it unknown. + code.push(...constant(0), ...put(listLocal(list, "count"))); + } + emitListRowAt(first, list, () => code.push(op.f64Const, ...float64(0))); + }; + + /** How many rows a list holds, as an i32 on the stack. */ + const emitListCount = (list: number): void => { + const { index } = indexOf(list); + if (index.kind === "hash") code.push(...get(listLocal(list, "length"))); + else code.push(...get(listLocal(list, "high")), ...get(listLocal(list, "low")), op.i32Sub, ...get(listLocal(list, "end")), ...get(listLocal(list, "second")), op.i32Sub, op.i32Add); + }; + + const emitListLength = (list: number): void => { + emitListCount(list); + code.push(op.f64ConvertI32U); + }; + + /** Points row handle `row` at row `index` (an f64 the callback leaves) of list handle `list`, or at none outside it. */ + const emitListRowAt = (row: number, list: number, index: () => void): void => { + const { lookup, index: structure } = indexOf(list); + const value = scratch(f64Type); + const position = scratch(i32Type); + const archetype = scratch(i32Type); + const entryRow = scratch(i32Type); + const entry = scratch(i32Type); + code.push(...constant(0), ...put(handleLocal(row, "found"))); + index(); + code.push(...tee(value), op.f64Const, ...float64(0), op.f64Ge, ...get(value)); + emitListCount(list); + code.push(op.f64ConvertI32U, op.f64Lt, op.i32And, op.if, 0x40); + code.push(...get(value), ...truncations.i32TruncSatF64U, ...put(position)); + if (structure.kind === "hash") { + code.push(...get(indexLocal(lookup, 3)), ...get(listLocal(list, "start")), ...get(position), op.i32Add, ...constant(12), op.i32Mul, op.i32Add, ...put(entry)); + code.push(...get(entry), ...load(4), ...put(archetype), ...get(entry), ...load(8), ...put(entryRow)); + } + else { + const runLength = scratch(i32Type); + const k = listLocal(list, "k"); + const first = listLocal(list, "first"); + const count = listLocal(list, "count"); + // The position among the entries: in the first run, or the second. + code.push(...get(listLocal(list, "high")), ...get(listLocal(list, "low")), op.i32Sub, ...tee(runLength), ...get(position), op.i32GtU); + code.push(op.if, i32Type, ...get(listLocal(list, "low")), ...get(position), op.i32Add, op.else, ...get(listLocal(list, "second")), ...get(position), op.i32Add, ...get(runLength), op.i32Sub, op.end, ...put(position)); + // Outside the cursor's block: find it. + code.push(...get(position), ...get(first), op.i32Sub, ...get(count), op.i32GeU, op.if, 0x40); + emitBlockAt(lookup, position, k); + emitPrefix(lookup, k); + code.push(...put(first)); + code.push(...get(indexLocal(lookup, 3)), ...get(indexLocal(lookup, 2)), ...get(k), ...constant(2), op.i32Shl, op.i32Add, ...load(0), ...constant(2), op.i32Shl, op.i32Add, ...load(0), ...put(count)); + code.push(op.end); + const entryBytes = 4 * structure.stride; + code.push(...get(indexLocal(lookup, 4)), ...get(indexLocal(lookup, 2)), ...get(k), ...constant(2), op.i32Shl, op.i32Add, ...load(0), ...constant(entryBytes * 128), op.i32Mul, op.i32Add); + code.push(...get(position), ...get(first), op.i32Sub, ...constant(entryBytes), op.i32Mul, op.i32Add, ...put(entry)); + code.push(...get(entry), ...load(4 * (structure.words + 1)), ...put(archetype), ...get(entry), ...load(4 * (structure.words + 2)), ...put(entryRow)); + } + bindRow(row, lookup, archetype, entryRow); + code.push(op.end); + }; + + const emitListRow = (row: number, list: number, index: Expression): void => emitListRowAt(row, list, () => emit(index)); + + /** Leaves 1 when a segment tree's ticks over the blocks around entry positions `low` to `high` (i32 locals) are newer than `since`. */ + const emitRunChanged = (lookup: number, tree: number, low: number, high: number): void => { + const from = scratch(i32Type); + const to = scratch(i32Type); + const position = scratch(i32Type); + const node = scratch(i32Type); + const limit = scratch(i32Type); + const maximum = scratch(f64Type); + const size = indexLocal(lookup, 6); + const leaves = indexLocal(lookup, 8); + // Clamped to the entries: max(low - 1, 0) and min(high, size - 1), then their blocks. + code.push(...get(low), ...constant(1), op.i32Sub, ...tee(position), ...constant(0), ...get(position), ...constant(0), op.i32GtS, op.select, ...tee(position)); + code.push(...get(size), ...constant(1), op.i32Sub, ...get(position), ...get(size), ...constant(1), op.i32Sub, op.i32LtS, op.select, ...put(position)); + emitBlockAt(lookup, position, from); + code.push(...get(high), ...get(size), ...constant(1), op.i32Sub, ...get(high), ...get(size), ...constant(1), op.i32Sub, op.i32LtS, op.select, ...put(position)); + emitBlockAt(lookup, position, to); + code.push(op.f64Const, ...float64(0), ...put(maximum)); + code.push(...get(from), ...get(leaves), op.i32Add, ...put(node), ...get(to), ...get(leaves), op.i32Add, ...constant(1), op.i32Add, ...put(limit)); + code.push(op.block, 0x40, op.loop, 0x40); + code.push(...get(node), ...get(limit), op.i32GeU, op.brIf, 1); + code.push(...get(node), ...constant(1), op.i32And, op.if, 0x40); + code.push(...get(maximum), ...get(tree), ...get(node), ...constant(3), op.i32Shl, op.i32Add, op.f64Load, 3, 0, op.f64Max, ...put(maximum), ...get(node), ...constant(1), op.i32Add, ...put(node)); + code.push(op.end); + code.push(...get(limit), ...constant(1), op.i32And, op.if, 0x40); + code.push(...get(limit), ...constant(1), op.i32Sub, ...tee(limit), ...constant(3), op.i32Shl, ...get(tree), op.i32Add, op.f64Load, 3, 0, ...get(maximum), op.f64Max, ...put(maximum)); + code.push(op.end); + code.push(...get(node), ...constant(1), op.i32ShrU, ...put(node), ...get(limit), ...constant(1), op.i32ShrU, ...put(limit), op.br, 0); + code.push(op.end, op.end); + code.push(...get(maximum), ...get(indexLocal(lookup, 10)), op.f64Gt); + }; + + /** Whether a list changed since its system last ran, as an f64 of 0 or 1. */ + const emitListChanged = (list: number): void => { + const { lookup, index } = indexOf(list); + if (index.kind === "hash") { + const bucket = listLocal(list, "bucket"); + const since = indexLocal(lookup, 6); + code.push(...get(bucket), ...constant(0), op.i32LtS, op.if, i32Type, ...get(indexLocal(lookup, 5)), ...get(since), op.f64Gt, op.else); + code.push(...get(indexLocal(lookup, 4)), ...get(bucket), ...constant(3), op.i32Shl, op.i32Add, op.f64Load, 3, 0, ...get(since), op.f64Gt); + for (let k = 0; k < index.watched; k++) code.push(...get(indexLocal(lookup, 7 + k)), ...get(bucket), ...constant(3), op.i32Shl, op.i32Add, op.f64Load, 3, 0, ...get(since), op.f64Gt, op.i32Or); + code.push(op.end, op.f64ConvertI32U); + return; + } + const trees = [indexLocal(lookup, 7), ...Array.from({ length: index.watched }, (_, k) => indexLocal(lookup, 11 + k))]; + code.push(...get(indexLocal(lookup, 1)), op.i32Eqz, op.if, i32Type, ...get(indexLocal(lookup, 9)), ...get(indexLocal(lookup, 10)), op.f64Gt, op.else); + code.push(...constant(0)); + for (const tree of trees) { + emitRunChanged(lookup, tree, listLocal(list, "low"), listLocal(list, "high")); + code.push(op.i32Or); + code.push(...get(listLocal(list, "end")), ...get(listLocal(list, "second")), op.i32GtS, op.if, i32Type); + emitRunChanged(lookup, tree, listLocal(list, "second"), listLocal(list, "end")); + code.push(op.else, ...constant(0), op.end, op.i32Or); + } + code.push(op.end, op.f64ConvertI32U); + }; + + /** A leaf of a row handle's row, through its directory record's column `word`, or NaN without a row. */ + const emitRecordLeaf = (handle: number, word: number, column: Omit): void => { + const { view, stride, offset, boolean } = column; + code.push(...get(handleLocal(handle, "found")), op.if, f64Type); + code.push(...get(handleLocal(handle, "record")), op.i32Load, 2, ...unsigned(4 * word)); + code.push(...get(handleLocal(handle, "row")), ...constant(stride), op.i32Mul, op.i32Add); + const memory = (align: number): number[] => [align, ...unsigned(offset)]; + if (boolean) code.push(view === "u8" ? op.i32Load8u : op.i32Load, ...memory(view === "u8" ? 0 : 2), op.i32Const, 0, op.i32Ne, op.f64ConvertI32U); + else if (view === "f32") code.push(op.f32Load, ...memory(2), op.f64PromoteF32); + else if (view === "i32") code.push(op.i32Load, ...memory(2), op.f64ConvertI32S); + else if (view === "u32") code.push(op.i32Load, ...memory(2), op.f64ConvertI32U); + else if (view === "u8") code.push(op.i32Load8u, ...memory(0), op.f64ConvertI32U); + else code.push(op.f64Load, ...memory(3)); + code.push(op.else, op.f64Const, ...float64(NaN), op.end); + }; + + const emitRowLeaf = (row: number, leaf: number): void => { + const { index } = indexOf(row); + const found = index.rowLeaf(leaf); + if (found === null) throw unsupported; + // Word 1 is the archetype's entities, so a read's column is at 2 + read. + emitRecordLeaf(row, 2 + found.read, found.column); + }; + + const emitRowEntity = (row: number): void => { + indexOf(row); + emitRecordLeaf(row, 1, { view: "f64", stride: 8, offset: 0, boolean: false }); + }; + + /** Sets the chunk tick of the row `rowOffset` rows past the current one, unless every row's chunk is stamped up front. */ + const stampChunk = (chunks: number, rowOffset: number): void => { + if (everyRow.has(chunks)) return; + code.push(op.localGet, ...unsigned(columnParameter(chunks)), op.localGet, ...unsigned(rowLocal)); + if (rowOffset !== 0) code.push(op.i32Const, ...signed(rowOffset), op.i32Add); + code.push(op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); + }; + + const statement = (current: Statement): void => { + switch (current.kind) { + case "set": emit(current.value); code.push(op.localSet, ...unsigned(local(current.local))); return; + case "store": return store(current.column, current.value); + // A chunk column stays when its row column is muted, for a component held on the GPU. + case "stamp": + if (columns[current.column].muted !== true) store(current.column, "tick"); + if (columns[current.chunks].muted === true) return; + return stampChunk(current.chunks, 0); + case "if": + emit(current.test); + emitTruthy(typeOf(current.test)); + code.push(op.if, 0x40); + labels.push("if"); + for (const inner of current.consequent) statement(inner); + if (current.alternate.length > 0) { + code.push(op.else); + for (const inner of current.alternate) statement(inner); + } + labels.pop(); + code.push(op.end); + return; + case "block": case "loop": + code.push(current.kind === "block" ? op.block : op.loop, 0x40); + labels.push("program"); + for (const inner of current.body) statement(inner); + labels.pop(); + code.push(op.end); + return; + case "branch": code.push(op.br, ...unsigned(wasmDepth(current.depth))); return; + case "branchIf": + emit(current.test); + emitTruthy(typeOf(current.test)); + code.push(op.brIf, ...unsigned(wasmDepth(current.depth))); + return; + case "next": code.push(op.br, ...unsigned(labels.length - 1 - labels.lastIndexOf("next"))); return; + case "find": return emitFind(current.handle, current.lookup, current.entity); + case "probe": return emitProbe(current.list, current.first, current.probe, current.values); + case "listRow": return emitListRow(current.row, current.list, current.index); + } + }; + + // ---- SIMD. Lanes compute in f32, at storage precision, so results may differ from the scalar path in the last bit. ---- + + const v128Type = 0x7b; + const simd = (operation: number): number[] => [0xfd, ...unsigned(operation)]; + const vop = { + load: 0, store: 11, const: 12, f32Splat: 19, f64Splat: 20, + f32Eq: 65, f32Ne: 66, f32Lt: 67, f32Gt: 68, f32Le: 69, f32Ge: 70, + f64Gt: 74, not: 77, and: 78, andNot: 79, or: 80, bitselect: 82, anyTrue: 83, + store32Lane: 90, store64Lane: 91, load32Zero: 92, load64Zero: 93, + f32Ceil: 103, f32Floor: 104, f32Trunc: 105, i64ExtendLowS: 199, i64ExtendHighS: 200, + f32Abs: 224, f32Neg: 225, f32Sqrt: 227, f32Add: 228, f32Sub: 229, f32Mul: 230, f32Div: 231, f32Min: 232, f32Max: 233, + } as const; + const float32 = (value: number): number[] => [...new Uint8Array(new Float32Array([value]).buffer)]; + const vectorConstant = (values: readonly number[]): number[] => [...simd(vop.const), ...[0, 1, 2, 3].flatMap(lane => float32(values[lane] ?? 0))]; + const splatConstant = (value: number): number[] => vectorConstant([value, value, value, value]); + const arithmeticVector = { "+": vop.f32Add, "-": vop.f32Sub, "*": vop.f32Mul, "/": vop.f32Div } as const; + const unaryVector: { readonly [name: string]: number | undefined } = { abs: vop.f32Abs, sqrt: vop.f32Sqrt, floor: vop.f32Floor, ceil: vop.f32Ceil, trunc: vop.f32Trunc }; + + const isArithmetic = (operator: string): operator is keyof typeof arithmeticVector => operator in arithmeticVector; + /** A value that's the same for every lane, splatted: demoted to f32, as a lane computes. */ + const emitSplat = (expression: Expression): void => { + emit(expression); + code.push(op.f32DemoteF64, ...simd(vop.f32Splat)); + }; + /** `Math` functions with an f32x4 equivalent; `fround` is the identity in f32. */ + const emitVectorMath = (functionName: string, count: number): void => { + if (functionName === "min" || functionName === "max") { + for (let i = 1; i < count; i++) code.push(...simd(functionName === "min" ? vop.f32Min : vop.f32Max)); + return; + } + const unary = unaryVector[functionName]; + if (unary !== undefined) code.push(...simd(unary)); + }; + const vectorMath = (functionName: string, count: number): boolean => + ((functionName === "min" || functionName === "max") && count > 0) || ((unaryVector[functionName] !== undefined || functionName === "fround") && count === 1); + + // ---- Across rows: four rows at a time, for straight-line programs whose columns are all dense f32. ---- + + /** The mask of the enclosing `if`s, or null outside any. */ + let mask: number | null = null; + const rowsLocals: number[] = []; + const rowsLocal = (index: number): number => rowsLocals[index] ??= scratch(v128Type); + + const denseAddress = (column: number): void => { + const { view, stride, boolean, component } = columns[column]; + if (view !== "f32" || stride !== 4 || boolean || component < 0) throw notVectorizable; + code.push(op.localGet, ...unsigned(columnParameter(column)), op.localGet, ...unsigned(rowLocal), op.i32Const, 4, op.i32Mul, op.i32Add); + }; + + const vectorTruthy = (type: Type): void => { + if (type === "boolean") { + code.push(...splatConstant(0), ...simd(vop.f32Ne)); + return; + } + const value = scratch(v128Type); + code.push(op.localTee, ...unsigned(value), ...splatConstant(0), ...simd(vop.f32Ne)); + code.push(op.localGet, ...unsigned(value), op.localGet, ...unsigned(value), ...simd(vop.f32Eq), ...simd(vop.and)); + }; + /** A lane mask as a value: 1 where set, 0 elsewhere. */ + const maskValue = (): void => { + code.push(...splatConstant(1), ...simd(vop.and)); + }; + + const rowsEmit = (expression: Expression): void => { + switch (expression.kind) { + case "number": code.push(...splatConstant(expression.value)); return; + case "boolean": code.push(...splatConstant(expression.value ? 1 : 0)); return; + case "local": code.push(op.localGet, ...unsigned(rowsLocal(expression.local))); return; + case "resource": case "args": return emitSplat(expression); + case "column": + denseAddress(expression.column); + code.push(...simd(vop.load), 2, ...unsigned(columns[expression.column].offset)); + return; + case "unary": + rowsEmit(expression.operand); + if (expression.operator === "-") code.push(...simd(vop.f32Neg)); + else if (expression.operator === "!") { + vectorTruthy(typeOf(expression.operand)); + code.push(...simd(vop.not)); + maskValue(); + } + else if (expression.operator === "~") throw notVectorizable; + return; + case "binary": { + const { operator, left, right } = expression; + if (operator === "===" || operator === "!==") { + const leftType = typeOf(left); + const rightType = typeOf(right); + if (leftType === "mixed" || rightType === "mixed") throw unsupported; + if (leftType !== rightType) { + code.push(...splatConstant(operator === "===" ? 0 : 1)); + return; + } + } + const comparison = { "<": vop.f32Lt, ">": vop.f32Gt, "<=": vop.f32Le, ">=": vop.f32Ge, "==": vop.f32Eq, "!=": vop.f32Ne, "===": vop.f32Eq, "!==": vop.f32Ne }[operator as string]; + if (!isArithmetic(operator) && comparison === undefined) throw notVectorizable; + rowsEmit(left); + rowsEmit(right); + if (isArithmetic(operator)) code.push(...simd(arithmeticVector[operator])); + else if (comparison !== undefined) { + code.push(...simd(comparison)); + maskValue(); + } + return; + } + case "logical": { + const value = scratch(v128Type); + rowsEmit(expression.left); + code.push(op.localSet, ...unsigned(value)); + // bitselect(a, b, m) takes a where m is set. + if (expression.operator === "&&") { + rowsEmit(expression.right); + code.push(op.localGet, ...unsigned(value)); + } + else { + code.push(op.localGet, ...unsigned(value)); + rowsEmit(expression.right); + } + code.push(op.localGet, ...unsigned(value)); + vectorTruthy(typeOf(expression.left)); + code.push(...simd(vop.bitselect)); + return; + } + case "conditional": + rowsEmit(expression.consequent); + rowsEmit(expression.alternate); + rowsEmit(expression.test); + vectorTruthy(typeOf(expression.test)); + code.push(...simd(vop.bitselect)); + return; + case "math": + if (!vectorMath(expression.name, expression.arguments.length)) throw notVectorizable; + for (const parameter of expression.arguments) rowsEmit(parameter); + emitVectorMath(expression.name, expression.arguments.length); + return; + case "found": case "lookupLeaf": case "upFound": case "upLeaf": case "listLength": case "listChanged": case "rowLeaf": case "rowEntity": throw notVectorizable; + } + }; + + /** Under a mask, keeps the old value (left on the stack below the new one) where the mask is clear. */ + const blend = (): void => { + if (mask === null) return; + code.push(op.localGet, ...unsigned(mask), ...simd(vop.bitselect)); + }; + + const rowsStatement = (current: Statement): void => { + switch (current.kind) { + case "set": { + const target = rowsLocal(current.local); + rowsEmit(current.value); + if (mask !== null) { + code.push(op.localGet, ...unsigned(target)); + blend(); + } + code.push(op.localSet, ...unsigned(target)); + return; + } + case "store": { + const value = scratch(v128Type); + rowsEmit(current.value); + if (mask !== null) { + rowsEmit({ kind: "column", column: current.column }); + blend(); + } + code.push(op.localSet, ...unsigned(value)); + denseAddress(current.column); + code.push(op.localGet, ...unsigned(value), ...simd(vop.store), 2, ...unsigned(columns[current.column].offset)); + return; + } + case "stamp": { + // Ticks are dense f64: four rows are two f64x2 stores; a chunk column stays when its row column is muted. + if (columns[current.column].muted !== true) { + for (let half = 0; half < 2; half++) { + const address = (): void => { code.push(op.localGet, ...unsigned(columnParameter(current.column)), op.localGet, ...unsigned(rowLocal), op.i32Const, 3, op.i32Shl, op.i32Add); }; + address(); + code.push(op.localGet, 2, ...simd(vop.f64Splat)); + if (mask !== null) { + address(); + code.push(...simd(vop.load), 3, ...unsigned(16 * half)); + code.push(op.localGet, ...unsigned(mask), ...simd(half === 0 ? vop.i64ExtendLowS : vop.i64ExtendHighS), ...simd(vop.bitselect)); + } + code.push(...simd(vop.store), 3, ...unsigned(16 * half)); + } + } + if (columns[current.chunks].muted === true) return; + // The group may straddle two chunks. Under a mask this can stamp a chunk no lane changed, which only costs a scan. + stampChunk(current.chunks, 0); + stampChunk(current.chunks, 3); + return; + } + case "if": { + const test = scratch(v128Type); + rowsEmit(current.test); + vectorTruthy(typeOf(current.test)); + code.push(op.localSet, ...unsigned(test)); + const outer = mask; + const branch = (statements: readonly Statement[], negate: boolean): void => { + if (statements.length === 0) return; + const inner = scratch(v128Type); + if (outer === null) { + code.push(op.localGet, ...unsigned(test)); + if (negate) code.push(...simd(vop.not)); + } + else { + code.push(op.localGet, ...unsigned(outer), op.localGet, ...unsigned(test), ...simd(negate ? vop.andNot : vop.and)); + } + code.push(op.localSet, ...unsigned(inner)); + mask = inner; + for (const statement of statements) rowsStatement(statement); + mask = outer; + }; + branch(current.consequent, false); + branch(current.alternate, true); + return; + } + default: throw notVectorizable; + } + }; + + /** Rows from `start` in groups of four, up to the last whole group; leaves the row local at the first row not done. */ + const rowsLoop = (): void => { + const vectorEnd = scratch(i32Type); + code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-4), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); + code.push(op.block, 0x40, op.loop, 0x40); + code.push(op.localGet, ...unsigned(rowLocal), op.localGet, ...unsigned(vectorEnd), op.i32GeS, op.brIf, 1); + mask = null; + for (const current of program.body) rowsStatement(current); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, 4, op.i32Add, op.localSet, ...unsigned(rowLocal), op.br, 0); + code.push(op.end, op.end); + }; + + // ---- Within a row (SLP): statements over a component's leaves that match lane for lane become one f32x4 operation. ---- + + type Window = { readonly component: number; readonly base: number; readonly lanes: number }; + type VectorExpression = + | { readonly kind: "pack"; readonly pack: Pack } + | { readonly kind: "load"; readonly column: number; readonly window: Window } + | { readonly kind: "constant"; readonly values: readonly number[] } + | { readonly kind: "splat"; readonly expression: Expression } + | { readonly kind: "binary"; readonly operator: keyof typeof arithmeticVector; readonly left: VectorExpression; readonly right: VectorExpression } + | { readonly kind: "negate"; readonly operand: VectorExpression } + | { readonly kind: "math"; readonly name: string; readonly arguments: readonly VectorExpression[] }; + /** Snapshot locals that hold a component's leaves, loaded as one vector at the first of their `set`s. */ + type Pack = { readonly first: number; readonly column: number; readonly window: Window; readonly lanes: Map; used: boolean; local: number }; + type Group = { readonly length: number; readonly column: number; readonly window: Window; readonly value: VectorExpression }; + + /** f32 leaves of one component, as consecutive lanes from the lowest offset; null if they can't be. */ + const windowOf = (laneColumns: readonly number[]): Window | null => { + const infos = laneColumns.map(column => columns[column]); + const first = infos[0]; + if (first === undefined || infos.some(info => info.view !== "f32" || info.boolean || info.component !== first.component || info.component < 0)) return null; + const base = Math.min(...infos.map(info => info.offset)); + const lanes = infos.map(info => (info.offset - base) / 4); + if (lanes.some(lane => lane > 3) || new Set(lanes).size !== lanes.length) return null; + return { component: first.component, base, lanes: Math.max(...lanes) + 1 }; + }; + const laneIn = (window: Window, column: number): number => (columns[column].offset - window.base) / 4; + + const elementAddress = (column: number): void => { + code.push(op.localGet, ...unsigned(columnParameter(column)), op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(columns[column].stride), op.i32Mul, op.i32Add); + }; + /** + * Loads a window's lanes. Three or four lanes load 16 bytes, which can read up to 12 past the element: + * columns keep `columnSlack` bytes after their last row for that. Two or one load only their lanes. + */ + const loadWindow = (column: number, { base, lanes }: Window): void => { + elementAddress(column); + if (lanes >= 3) return void code.push(...simd(vop.load), 2, ...unsigned(base)); + code.push(...simd(lanes === 1 ? vop.load32Zero : vop.load64Zero), 2, ...unsigned(base)); + }; + /** Stores a window's lanes from a v128 local, without touching the rest of the element. */ + const storeWindow = (column: number, { base, lanes }: Window, value: number): void => { + elementAddress(column); + code.push(op.localGet, ...unsigned(value)); + if (lanes === 4) return void code.push(...simd(vop.store), 2, ...unsigned(base)); + code.push(...simd(vop.store64Lane), 2, ...unsigned(base), 0); + if (lanes !== 3) return; + elementAddress(column); + code.push(op.localGet, ...unsigned(value), ...simd(vop.store32Lane), 2, ...unsigned(base + 8), 2); + }; + + /** Plans the fusion of top-level statements. Statement index to group, and which `set`s only feed groups. */ + const planLanes = (): { readonly groups: ReadonlyMap; readonly packs: ReadonlyMap; readonly skipped: ReadonlySet } => { + const body = program.body; + const assignments = new Map(); + const uses = new Map(); + const countExpression = (expression: Expression): void => { + switch (expression.kind) { + case "local": uses.set(expression.local, (uses.get(expression.local) ?? 0) + 1); return; + case "unary": return countExpression(expression.operand); + case "binary": case "logical": countExpression(expression.left); return countExpression(expression.right); + case "conditional": countExpression(expression.test); countExpression(expression.consequent); return countExpression(expression.alternate); + case "math": for (const parameter of expression.arguments) countExpression(parameter); return; + default: return; + } + }; + const countStatements = (statements: readonly Statement[]): void => { + for (const statement of statements) { + switch (statement.kind) { + case "set": assignments.set(statement.local, (assignments.get(statement.local) ?? 0) + 1); countExpression(statement.value); break; + case "store": countExpression(statement.value); break; + case "branchIf": countExpression(statement.test); break; + case "find": countExpression(statement.entity); break; + case "if": countExpression(statement.test); countStatements(statement.consequent); countStatements(statement.alternate); break; + case "block": case "loop": countStatements(statement.body); break; + default: break; + } + } + }; + countStatements(body); + + // Packs: single-assignment snapshot locals of one component's leaves, with no store to it between their sets. + const packOfLocal = new Map(); + const open = new Map(); + const packs = new Map(); + const close = (component: number): void => { + const pending = open.get(component); + open.delete(component); + if (pending === undefined || pending.columns.length < 2) return; + const window = windowOf(pending.columns); + if (window === null) return; + const pack: Pack = { first: pending.first, column: pending.columns[0], window, lanes: new Map(), used: false, local: -1 }; + pending.locals.forEach((local, i) => { + pack.lanes.set(local, laneIn(window, pending.columns[i])); + packOfLocal.set(local, pack); + }); + packs.set(pending.first, pack); + }; + body.forEach((statement, index) => { + if (statement.kind === "set" && statement.value.kind === "column" && assignments.get(statement.local) === 1) { + const info = columns[statement.value.column]; + if (info.view === "f32" && !info.boolean && info.component >= 0) { + const pending = open.get(info.component) ?? { first: index, columns: [], locals: [] }; + open.set(info.component, pending); + pending.columns.push(statement.value.column); + pending.locals.push(statement.local); + return; + } + } + if (statement.kind === "store") close(columns[statement.column].component); + if (statement.kind !== "set") for (const component of [...open.keys()]) close(component); + }); + for (const component of [...open.keys()]) close(component); + + /** Whether an expression reads a leaf (by component and offset) in `written`. */ + const reads = (expression: Expression, written: ReadonlySet): boolean => { + switch (expression.kind) { + case "column": return written.has(`${columns[expression.column].component}:${columns[expression.column].offset}`); + case "unary": return reads(expression.operand, written); + case "binary": case "logical": return reads(expression.left, written) || reads(expression.right, written); + case "conditional": return reads(expression.test, written) || reads(expression.consequent, written) || reads(expression.alternate, written); + case "math": return expression.arguments.some(parameter => reads(parameter, written)); + default: return false; + } + }; + /** Fused stores read everything before writing, so no store may read a leaf an earlier one in the group wrote. */ + const readsEarlierStore = (stores: readonly Extract[]): boolean => { + const written = new Set(); + for (const store of stores) { + if (reads(store.value, written)) return true; + written.add(`${columns[store.column].component}:${columns[store.column].offset}`); + } + return false; + }; + const groupUses = new Map(); + /** Matches one expression per lane; null if they don't line up. */ + const match = (lanes: readonly Expression[]): VectorExpression | null => { + const first = lanes[0]; + if (lanes.every(lane => JSON.stringify(lane) === JSON.stringify(first))) return { kind: "splat", expression: first }; + if (lanes.every(lane => lane.kind === "number")) return { kind: "constant", values: lanes.map(lane => lane.kind === "number" ? lane.value : 0) }; + if (first.kind === "local") { + const pack = packOfLocal.get(first.local); + if (pack !== undefined && lanes.every((lane, i) => lane.kind === "local" && packOfLocal.get(lane.local) === pack && pack.lanes.get(lane.local) === i)) { + for (const lane of lanes) if (lane.kind === "local") groupUses.set(lane.local, (groupUses.get(lane.local) ?? 0) + 1); + return { kind: "pack", pack }; + } + return null; + } + if (first.kind === "column") { + const laneColumns = lanes.flatMap(lane => lane.kind === "column" ? [lane.column] : []); + const window = laneColumns.length === lanes.length ? windowOf(laneColumns) : null; + return window !== null && laneColumns.every((column, i) => laneIn(window, column) === i) ? { kind: "load", column: laneColumns[0], window } : null; + } + if (first.kind === "binary" && isArithmetic(first.operator)) { + const { operator } = first; + const pairs = lanes.flatMap(lane => lane.kind === "binary" && lane.operator === operator ? [lane] : []); + if (pairs.length !== lanes.length) return null; + const left = match(pairs.map(pair => pair.left)); + const right = left === null ? null : match(pairs.map(pair => pair.right)); + return left === null || right === null ? null : { kind: "binary", operator, left, right }; + } + if (first.kind === "unary" && (first.operator === "-" || first.operator === "+")) { + const operator = first.operator; + const operands = lanes.flatMap(lane => lane.kind === "unary" && lane.operator === operator ? [lane.operand] : []); + if (operands.length !== lanes.length) return null; + const operand = match(operands); + return operand === null ? null : operator === "-" ? { kind: "negate", operand } : operand; + } + if (first.kind === "math" && vectorMath(first.name, first.arguments.length)) { + const calls = lanes.flatMap(lane => lane.kind === "math" && lane.name === first.name && lane.arguments.length === first.arguments.length ? [lane] : []); + if (calls.length !== lanes.length) return null; + const parameters: VectorExpression[] = []; + for (let i = 0; i < first.arguments.length; i++) { + const parameter = match(calls.map(call => call.arguments[i])); + if (parameter === null) return null; + parameters.push(parameter); + } + return { kind: "math", name: first.name, arguments: parameters }; + } + return null; + }; + + // Groups: consecutive stores to two or more leaves of one component whose values match lane for lane. + const groups = new Map(); + for (let index = 0; index < body.length;) { + const statement = body[index]; + if (statement.kind !== "store") { + index++; + continue; + } + const component = columns[statement.column].component; + let end = index; + while (end < body.length) { + const next = body[end]; + if (next.kind !== "store" || columns[next.column].component !== component) break; + end++; + } + const stores = body.slice(index, end).flatMap(store => store.kind === "store" ? [store] : []); + const window = stores.length >= 2 ? windowOf(stores.map(store => store.column)) : null; + const lanes: Expression[] = []; + if (window !== null && window.lanes === stores.length) for (const store of stores) lanes[laneIn(window, store.column)] = store.value; + const before = new Map(groupUses); + const value = window !== null && window.lanes === stores.length && !readsEarlierStore(stores) ? match(lanes) : null; + if (window !== null && value !== null) { + groups.set(index, { length: stores.length, column: stores[0].column, window, value }); + const markUsed = (expression: VectorExpression): void => { + if (expression.kind === "pack") expression.pack.used = true; + else if (expression.kind === "binary") { markUsed(expression.left); markUsed(expression.right); } + else if (expression.kind === "negate") markUsed(expression.operand); + else if (expression.kind === "math") expression.arguments.forEach(markUsed); + }; + markUsed(value); + } + else { + groupUses.clear(); + for (const [local, count] of before) groupUses.set(local, count); + } + index = end; + } + + // A snapshot `set` whose local is only read through vectors needn't run as a scalar. + const skipped = new Set(); + body.forEach((statement, index) => { + if (statement.kind === "set" && packOfLocal.get(statement.local)?.used === true && (uses.get(statement.local) ?? 0) === (groupUses.get(statement.local) ?? 0)) skipped.add(index); + }); + return { groups, packs, skipped }; + }; + + const emitVectorExpression = (expression: VectorExpression): void => { + switch (expression.kind) { + case "pack": code.push(op.localGet, ...unsigned(expression.pack.local)); return; + case "load": return loadWindow(expression.column, expression.window); + case "constant": code.push(...vectorConstant(expression.values)); return; + case "splat": return emitSplat(expression.expression); + case "binary": + emitVectorExpression(expression.left); + emitVectorExpression(expression.right); + code.push(...simd(arithmeticVector[expression.operator])); + return; + case "negate": + emitVectorExpression(expression.operand); + code.push(...simd(vop.f32Neg)); + return; + case "math": + for (const parameter of expression.arguments) emitVectorExpression(parameter); + emitVectorMath(expression.name, expression.arguments.length); + return; + } + }; + + /** The row body, with planned groups fused; returns whether anything was. */ + const emitLaneBody = (): boolean => { + const { groups, packs, skipped } = planLanes(); + const body = program.body; + for (let index = 0; index < body.length; index++) { + const pack = packs.get(index); + if (pack?.used === true) { + pack.local = scratch(v128Type); + loadWindow(pack.column, pack.window); + code.push(op.localSet, ...unsigned(pack.local)); + } + const group = groups.get(index); + if (group !== undefined) { + const value = scratch(v128Type); + emitVectorExpression(group.value); + code.push(op.localSet, ...unsigned(value)); + storeWindow(group.column, group.window, value); + index += group.length - 1; + continue; + } + if (!skipped.has(index)) statement(body[index]); + } + return groups.size > 0; + }; + + // ---- Across rows for interleaved elements: four rows of a `stride`-byte element are `stride / 4` whole vectors. ---- + + type LanePlan = ReturnType; + /** Whether a value is the same for every row. */ + const rowIndependent = (expression: Expression): boolean => { + switch (expression.kind) { + case "number": case "boolean": case "resource": case "args": return true; + case "unary": return rowIndependent(expression.operand); + case "binary": case "logical": return rowIndependent(expression.left) && rowIndependent(expression.right); + case "conditional": return rowIndependent(expression.test) && rowIndependent(expression.consequent) && rowIndependent(expression.alternate); + case "math": return expression.arguments.every(rowIndependent); + default: return false; + } + }; + /** + * Whether the planned groups are the whole body and run unchanged on flat vectors of four rows: each group stores + * whole elements and reads windows from byte 0 of components with the same stride, so lane `l` of vector `j` is the + * same leaf in every operand. Snapshot sets must be skipped, and stamps run as in the across-rows loop. + */ + const acrossElements = ({ groups, skipped }: LanePlan): boolean => { + if (filters > 0 || groups.size === 0) return false; + const whole = (column: number, window: Window, stride: number): boolean => window.base === 0 && columns[column].stride === stride; + const fits = (expression: VectorExpression, stride: number): boolean => { + switch (expression.kind) { + case "pack": return whole(expression.pack.column, expression.pack.window, stride); + case "load": return whole(expression.column, expression.window, stride); + case "constant": return true; + case "splat": return rowIndependent(expression.expression); + case "binary": return fits(expression.left, stride) && fits(expression.right, stride); + case "negate": return fits(expression.operand, stride); + case "math": return expression.arguments.every(parameter => fits(parameter, stride)); + } + }; + const body = program.body; + for (let index = 0; index < body.length; index++) { + const group = groups.get(index); + if (group !== undefined) { + const { stride } = columns[group.column]; + if (group.window.base !== 0 || group.window.lanes * 4 !== stride || !fits(group.value, stride)) return false; + index += group.length - 1; + } + else if (!skipped.has(index) && body[index].kind !== "stamp") return false; + } + return true; + }; + + /** Vector `vector` of an expression over four rows of `stride`-byte elements; packs are in `packVectors`. */ + const emitElementExpression = (expression: VectorExpression, vector: number, stride: number, packVectors: ReadonlyMap): void => { + switch (expression.kind) { + case "pack": { + // Every pack a group uses is loaded at its first set, before the group. + const local = packVectors.get(expression.pack)?.[vector]; + if (local === undefined) throw new Error("A pack is used before it's loaded."); + code.push(op.localGet, ...unsigned(local)); + return; + } + case "load": + elementAddress(expression.column); + code.push(...simd(vop.load), 2, ...unsigned(16 * vector)); + return; + case "constant": code.push(...vectorConstant([0, 1, 2, 3].map(lane => expression.values[((16 * vector + 4 * lane) % stride) / 4]))); return; + case "splat": return emitSplat(expression.expression); + case "binary": + emitElementExpression(expression.left, vector, stride, packVectors); + emitElementExpression(expression.right, vector, stride, packVectors); + code.push(...simd(arithmeticVector[expression.operator])); + return; + case "negate": + emitElementExpression(expression.operand, vector, stride, packVectors); + code.push(...simd(vop.f32Neg)); + return; + case "math": + for (const parameter of expression.arguments) emitElementExpression(parameter, vector, stride, packVectors); + emitVectorMath(expression.name, expression.arguments.length); + return; + } + }; + + /** + * Rows from `start` in groups of four, up to the last whole group, with every group as `stride / 4` full-vector loads, + * operations and stores; leaves the row local at the first row not done. Vectors stay inside the four rows' elements, + * and each vector's store only touches bytes no other vector reads. + */ + const elementsLoop = ({ groups, packs }: LanePlan): void => { + const vectorEnd = scratch(i32Type); + code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-4), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); + code.push(op.block, 0x40, op.loop, 0x40); + code.push(op.localGet, ...unsigned(rowLocal), op.localGet, ...unsigned(vectorEnd), op.i32GeS, op.brIf, 1); + mask = null; + const packVectors = new Map(); + const body = program.body; + for (let index = 0; index < body.length; index++) { + const pack = packs.get(index); + if (pack?.used === true) { + const vectors = Array.from({ length: columns[pack.column].stride / 4 }, (_, vector) => { + const local = scratch(v128Type); + elementAddress(pack.column); + code.push(...simd(vop.load), 2, ...unsigned(16 * vector), op.localSet, ...unsigned(local)); + return local; + }); + packVectors.set(pack, vectors); + } + const group = groups.get(index); + if (group !== undefined) { + const { stride } = columns[group.column]; + for (let vector = 0; vector < stride / 4; vector++) { + const value = scratch(v128Type); + emitElementExpression(group.value, vector, stride, packVectors); + code.push(op.localSet, ...unsigned(value)); + elementAddress(group.column); + code.push(op.localGet, ...unsigned(value), ...simd(vop.store), 2, ...unsigned(16 * vector)); + } + index += group.length - 1; + } + else if (body[index].kind === "stamp") rowsStatement(body[index]); + } + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, 4, op.i32Add, op.localSet, ...unsigned(rowLocal), op.br, 0); + code.push(op.end, op.end); + }; + + try { + const parameterCount = 4 + columns.length + 2 * filters + lookupParameters + indexParameters + upParameters; + collectFinds(program.body); + /** Leaves 1 if any filter's tick at `(row >> shift) * 8` in its `parameter` array is newer than `lastRun`. */ + const anyNewer = (parameter: (filter: number) => number, shift: number): void => { + for (let filter = 0; filter < filters; filter++) { + code.push(op.localGet, ...unsigned(parameter(filter)), op.localGet, ...unsigned(rowLocal)); + if (shift > 0) code.push(op.i32Const, ...signed(shift), op.i32ShrU); + code.push(op.i32Const, 3, op.i32Shl, op.i32Add, op.f64Load, 3, 0, op.localGet, 3, op.f64Gt); + if (filter > 0) code.push(op.i32Or); + } + }; + /** At a chunk's first row, skips the whole chunk unless a filter's chunk tick is newer. */ + const skipChunks = (): void => { + const next = scratch(i32Type); + code.push(op.localGet, ...unsigned(rowLocal), op.localGet, 0, op.i32Eq, op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(2 ** chunkShift - 1), op.i32And, op.i32Eqz, op.i32Or); + code.push(op.if, 0x40); + anyNewer(filterChunkParameter, chunkShift); + code.push(op.i32Eqz, op.if, 0x40); + // row = min(end, start of the next chunk) + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, 1, op.i32Add, op.i32Const, ...signed(chunkShift), op.i32Shl, op.localSet, ...unsigned(next)); + code.push(op.localGet, ...unsigned(next), op.localGet, 1, op.localGet, ...unsigned(next), op.localGet, 1, op.i32LtS, op.select, op.localSet, ...unsigned(rowLocal)); + code.push(op.br, 2, op.end, op.end); + }; + /** Rows a block skip checks at once: four f64x2 loads of each filter's row ticks. */ + const blockRows = 8; + /** + * With SIMD, at a row that's a multiple of `blockRows`, skips that many rows when none passes a filter, testing + * two ticks per comparison. Only aligned rows check, so rows that do pass pay one check per block. + */ + const skipBlocks = (): void => { + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows - 1, op.i32And, op.i32Eqz); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows, op.i32Add, op.localGet, 1, op.i32LeS, op.i32And, op.if, 0x40); + for (let filter = 0; filter < filters; filter++) { + for (let pair = 0; pair < blockRows / 2; pair++) { + code.push(op.localGet, ...unsigned(filterRowParameter(filter)), op.localGet, ...unsigned(rowLocal), op.i32Const, 3, op.i32Shl, op.i32Add); + code.push(...simd(vop.load), 3, ...unsigned(16 * pair), op.localGet, 3, ...simd(vop.f64Splat), ...simd(vop.f64Gt)); + if (filter > 0 || pair > 0) code.push(...simd(vop.or)); + } + } + code.push(...simd(vop.anyTrue), op.i32Eqz, op.if, 0x40); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows, op.i32Add, op.localSet, ...unsigned(rowLocal)); + code.push(op.br, 2, op.end, op.end); + }; + // Scalars are discovered while generating, so code is generated first with locals placed after a provisional scalar count. + /** `rows`: four rows at a time where every column is dense; `lanes`: fuse statements within each row. */ + let fused = false; + const generate = (scalarCount: number, mode: "rows" | "lanes" | "scalar"): void => { + code.length = 0; + extraLocals.length = 0; + rowsLocals.length = 0; + // With reductions, the partials' address follows the scalars. + const partialsParameter = parameterCount + scalarCount; + localBase = partialsParameter + (accumulators.length > 0 ? 1 : 0); + rowLocal = scratch(i32Type); + code.push(op.localGet, 0, op.localSet, ...unsigned(rowLocal)); + accumulators.forEach((accumulator, k) => { + code.push(op.localGet, ...unsigned(partialsParameter), op.f64Load, 3, ...unsigned(8 * k), op.localSet, ...unsigned(local(accumulator))); + }); + // Floating-point folds wait on the one before, so consecutive rows fold into one of four lanes in turn: + // the accumulator, rotated with three more starting at the identity, then combined in a fixed order. + const lanes = accumulators.map((_, k) => { + const operator = operators[k]; + const identity = rotated[operator as keyof typeof rotated]; + if (identity === undefined) return null; + return [0, 1, 2].map(() => { + const lane = scratch(f64Type); + code.push(op.f64Const, ...float64(identity.value), op.localSet, ...unsigned(lane)); + return lane; + }); + }); + handleLocals.clear(); + keyLocals.clear(); + if (lookupParameters > 0) convertLookupParameters(); + if (indexParameters > 0) convertIndexParameters(); + stampRangeChunks(); + if (mode === "rows") rowsLoop(); + if (mode === "lanes") { + const plan = planLanes(); + if (acrossElements(plan)) elementsLoop(plan); + } + // for (; row < end; row++) { block next { body } }, where with `up` the row and its parent's are rows[n] and parents[n]. + const slot = rowLocal; + if (upMode) { + rowLocal = scratch(i32Type); + parentLocal = scratch(i32Type); + } + code.push(op.block, 0x40, op.loop, 0x40); + code.push(op.localGet, ...unsigned(slot), op.localGet, 1, op.i32GeS, op.brIf, 1); + if (upMode) { + code.push(op.localGet, ...unsigned(upParameter(0)), op.localGet, ...unsigned(slot), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localSet, ...unsigned(rowLocal)); + code.push(op.localGet, ...unsigned(upParameter(1)), op.localGet, ...unsigned(slot), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localSet, ...unsigned(parentLocal)); + } + if (filters > 0) skipChunks(); + if (filters > 0 && simdEnabled && !upMode) skipBlocks(); + code.push(op.block, 0x40); + // A row that no filter passes goes to the next. + if (filters > 0) { + anyNewer(filterRowParameter, 0); + code.push(op.i32Eqz, op.brIf, 0); + } + labels.length = 0; + labels.push("next"); + if (mode === "lanes") fused = emitLaneBody(); + else for (const current of program.body) statement(current); + labels.pop(); + code.push(op.end); + accumulators.forEach((accumulator, k) => { + const extra = lanes[k]; + if (extra === null) return; + const [first, second, third] = extra; + const own = unsigned(local(accumulator)); + code.push(op.localGet, ...own, op.localGet, ...unsigned(first), op.localSet, ...own); + code.push(op.localGet, ...unsigned(second), op.localSet, ...unsigned(first), op.localGet, ...unsigned(third), op.localSet, ...unsigned(second)); + code.push(op.localSet, ...unsigned(third)); + }); + code.push(op.localGet, ...unsigned(slot), op.i32Const, 1, op.i32Add, op.localSet, ...unsigned(slot), op.br, 0); + code.push(op.end, op.end); + rowLocal = slot; + accumulators.forEach((accumulator, k) => { + const extra = lanes[k]; + if (extra === null) return; + const combine = rotated[operators[k] as keyof typeof rotated].combine; + const [first, second, third] = extra; + const own = unsigned(local(accumulator)); + code.push(op.localGet, ...own, op.localGet, ...unsigned(first), combine, op.localGet, ...unsigned(second), op.localGet, ...unsigned(third), combine, combine, op.localSet, ...own); + }); + accumulators.forEach((accumulator, k) => { + code.push(op.localGet, ...unsigned(partialsParameter), op.localGet, ...unsigned(local(accumulator)), op.f64Store, 3, ...unsigned(8 * k)); + }); + // The function's end. + code.push(op.end); + }; + // Across-rows mode doesn't filter rows, so filtered kernels fuse within rows instead. + // Accumulators carry from row to row, which neither SIMD mode keeps. + // Listed rows aren't consecutive, so `up` programs run one row at a time. + let mode: "rows" | "lanes" | "scalar" = !simdEnabled || accumulators.length > 0 || upMode ? "scalar" : filters > 0 ? "lanes" : "rows"; + try { + generate(0, mode); + } + catch (error) { + if (error !== notVectorizable) throw error; + mode = "lanes"; + generate(0, mode); + } + if (mode === "lanes" && !fused) { + mode = "scalar"; + generate(0, mode); + } + const discovered = scalars.length; + generate(discovered, mode); + if (scalars.length !== discovered) throw unsupported; + const vectorized = mode !== "scalar"; + + const parameterTypes = [i32Type, i32Type, f64Type, f64Type, ...columns.map(() => i32Type), ...new Array(2 * filters).fill(i32Type), ...new Array(lookupParameters).fill(f64Type), ...new Array(indexParameters).fill(f64Type), ...new Array(upParameters).fill(i32Type), ...scalars.map(() => f64Type), ...accumulators.length > 0 ? [i32Type] : []]; + const localTypesList = [...new Array(program.locals).fill(f64Type)]; + // Locals in order: program locals, then extras in creation order; the row index was the first extra. + const declared = [...localTypesList.map(type => [1, type]), ...extraLocals.map(type => [1, type])]; + const body = [...vector(declared.map(([count, type]) => [...unsigned(count), type])), ...code]; + const types = [ + [0x60, ...vector(parameterTypes.map(type => [type])), ...vector([])], + [0x60, ...vector([[f64Type], [f64Type]]), ...vector([[f64Type]])], + ]; + const importEntries = [ + [...name("env"), ...name("memory"), 0x02, 0x03, ...unsigned(1), ...unsigned(65536)], + ...imports.map(functionName => [...name("math"), ...name(functionName), 0x00, ...unsigned(1)]), + ]; + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + ...section(1, vector(types)), + ...section(2, vector(importEntries)), + ...section(3, vector([unsigned(0)])), + ...section(7, vector([[...name("run"), 0x00, ...unsigned(imports.length)]])), + ...section(10, vector([[...unsigned(body.length), ...body]])), + ]); + return { bytes, scalars, imports, vectorized }; + } + catch (error) { + if (error === unsupported) return null; + throw error; + } +}; diff --git a/packages/ecs/src/implementation/world/wgsl.test.ts b/packages/ecs/src/implementation/world/wgsl.test.ts new file mode 100644 index 000000000..a2a019224 --- /dev/null +++ b/packages/ecs/src/implementation/world/wgsl.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; +import type { Program } from "./kernel.js"; +import { generateWgsl } from "./wgsl.js"; +import type { WasmColumn } from "./wasm.js"; + +const columns: readonly WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }]; + +describe("generateWgsl", () => { + test("names locals after their JS names, made valid WGSL identifiers", () => { + const program: Program = { + locals: 3, + names: ["speed_0", "$value_1", "__hidden_2"], + body: [0, 1, 2].map(local => ({ kind: "set", local, value: { kind: "column", column: 0 } })), + }; + const kernel = generateWgsl(program, columns, () => "number"); + const code = typeof kernel === "string" ? kernel : kernel.code; + expect(code).toContain("var speed_0: f32"); + expect(code).toContain("var v_value_1: f32"); + expect(code).toContain("var v__hidden_2: f32"); + }); + + test("uses WGSL's own operations where JS differs only for NaN or infinite values", () => { + const a: Program.Expression = { kind: "column", column: 0 }; + const program: Program = { + locals: 0, + body: [{ + kind: "store", column: 0, value: { + kind: "binary", operator: "+", + left: { kind: "math", name: "min", arguments: [a, { kind: "math", name: "asinh", arguments: [a] }] }, + right: { kind: "binary", operator: "%", left: a, right: { kind: "number", value: 1.5 } }, + }, + }], + }; + const kernel = generateWgsl(program, columns, () => "number"); + const code = typeof kernel === "string" ? kernel : kernel.code; + expect(code).toContain("min(bitcast"); + expect(code).toContain("asinh("); + expect(code).toMatch(/\) % 1\.5e\+0f\)/); + expect(code).not.toMatch(/jsMin|remainder|isNan/); + }); +}); diff --git a/packages/ecs/src/implementation/world/wgsl.ts b/packages/ecs/src/implementation/world/wgsl.ts new file mode 100644 index 000000000..f9ad7a3e8 --- /dev/null +++ b/packages/ecs/src/implementation/world/wgsl.ts @@ -0,0 +1,464 @@ +import { laneCount } from './intrinsics.js'; +import type { Program } from './kernel.js'; +import type { WasmColumn, WasmScalar } from './wasm.js'; + +type Expression = Program.Expression; +type Statement = Program.Statement; + +/** + * A kernel as a WGSL compute shader over the GPU copy of the arena. Numbers are f32, and integer reads and bitwise + * results stay i32 or u32: results are not bit-exact with JS. Each dispatch reads a parameter record of `u32` words: the row count, then each column's word address in + * this archetype, then the scalars as f32 bits. + */ +export type WgslKernel = { + readonly code: string; + /** Which resource and args leaves follow the column addresses, as in `WasmKernel.scalars`. */ + readonly scalars: readonly WasmScalar[]; +}; + +/** + * A local's WGSL identifier: its JS name with characters WGSL doesn't allow replaced. JS names end in `_`, + * so they stay unique and never collide with WGSL keywords or the shader's other names. + */ +const identifier = (name: string): string => { + const safe = name.replace(/[^A-Za-z0-9_]/g, "_"); + return safe.startsWith("_") ? `v${safe}` : safe; +}; + +/** A WGSL value type's element type. */ +const elementKind = (type: Program.ValueType): "f32" | "i32" | "u32" => + type === "i32" || type.endsWith("i") ? "i32" : type === "u32" || type.endsWith("u") ? "u32" : "f32"; + +/** A lane of a value: the value itself, a vector component, or a matrix element by column and row. */ +const laneOf = (variable: string, type: Program.ValueType, lane: number): string => + type === "mat4x4f" ? `${variable}[${lane >> 2}][${lane & 3}]` : laneCount(type) === 1 ? variable : `${variable}.${"xyzw"[lane]}`; + +type Region = Extract; + +/** Locals an expression reads. A lane reads its region's result, not its value. */ +const localsIn = (expression: Expression, into: Set): void => { + switch (expression.kind) { + case "local": into.add(expression.local); return; + case "unary": localsIn(expression.operand, into); return; + case "binary": case "logical": localsIn(expression.left, into); localsIn(expression.right, into); return; + case "conditional": for (const item of [expression.test, expression.consequent, expression.alternate]) localsIn(item, into); return; + case "math": for (const item of expression.arguments) localsIn(item, into); return; + default: return; + } +}; + +/** Locals the shader uses: those intrinsic bodies alone use aren't emitted. */ +const usedLocals = (body: readonly Statement[], into: Set = new Set()): Set => { + for (const statement of body) { + switch (statement.kind) { + case "set": into.add(statement.local); localsIn(statement.value, into); break; + case "store": localsIn(statement.value, into); break; + case "if": localsIn(statement.test, into); usedLocals(statement.consequent, into); usedLocals(statement.alternate, into); break; + case "intrinsic": for (const lanes of statement.operands) for (const lane of lanes) localsIn(lane, into); break; + default: break; + } + } + return into; +}; + +const regionsOf = (body: readonly Statement[]): Region[] => body.flatMap(statement => + statement.kind === "intrinsic" ? [statement] : statement.kind === "if" ? [...regionsOf(statement.consequent), ...regionsOf(statement.alternate)] : []); + +/** Rows per workgroup, and workgroups per dispatch dimension: WebGPU's default limit. */ +export const workgroupSize = 64; +export const maximumWorkgroups = 65535; + +/** Thrown to end generation with the reason a kernel can't run on the GPU. */ +type Unsupported = { readonly unsupported: string }; +const fail = (reason: string): never => { + const unsupported: Unsupported = { unsupported: reason }; + throw unsupported; +}; +const isUnsupported = (error: unknown): error is Unsupported => + typeof error === "object" && error !== null && "unsupported" in error && typeof error.unsupported === "string"; + +/** + * An emitted value and its WGSL type. Integer column reads and bitwise results stay `i32` or `u32`, so integer chains + * keep every bit; arithmetic converts to f32. `literal` keeps a number literal's exact value for integer use. + */ +type Kind = "f32" | "bool" | "i32" | "u32"; +type Value = { readonly code: string; readonly type: Kind; readonly literal?: number }; + +const f32 = (code: string): Value => ({ code, type: "f32" }); +const bool = (code: string): Value => ({ code, type: "bool" }); +const i32 = (code: string): Value => ({ code, type: "i32" }); +const u32 = (code: string): Value => ({ code, type: "u32" }); + +/** JS `Number(value)`. */ +const asNumber = (value: Value): string => { + switch (value.type) { + case "f32": return value.code; + case "bool": return `select(0.0, 1.0, ${value.code})`; + default: return `f32(${value.code})`; + } +}; + +/** JS truthiness; for NaN, WGSL leaves it indeterminate. */ +const asBool = (value: Value): string => { + switch (value.type) { + case "bool": return value.code; + case "f32": return `(${value.code} != 0.0)`; + case "i32": return `(${value.code} != 0i)`; + case "u32": return `(${value.code} != 0u)`; + } +}; + +/** JS `ToUint32`, as bits. */ +const asUint = (value: Value): string => { + if (value.literal !== undefined && Number.isFinite(value.literal)) return `${value.literal >>> 0}u`; + switch (value.type) { + case "u32": return value.code; + case "i32": return `bitcast(${value.code})`; + case "bool": return `select(0u, 1u, ${value.code})`; + case "f32": return `toUint32(${value.code})`; + } +}; + +/** JS `ToInt32`. */ +const asInt = (value: Value): string => value.type === "i32" ? value.code : `bitcast(${asUint(value)})`; + +const literal = (value: number): string => { + // Constant expressions can't be NaN or infinite, so these come from private variables. + if (Number.isNaN(value)) return "bitcast(nanBits)"; + if (Math.abs(value) > 3.4028234663852886e38) return value > 0 ? "bitcast(infinityBits)" : "(-bitcast(infinityBits))"; + // Below f32's smallest normal: flush rather than fail as an unrepresentable literal. + if (value !== 0 && Math.abs(value) < 1.1754943508222875e-38) return "0.0"; + const text = Number.isInteger(value) && Math.abs(value) < 2 ** 53 ? value.toFixed(1) : value.toExponential(); + return value < 0 ? `(${text}f)` : `${text}f`; +}; + +/** `Math` functions with a WGSL equivalent, by argument count. */ +const unaryMath: { readonly [name: string]: string } = { + abs: "abs", sqrt: "sqrt", floor: "floor", ceil: "ceil", trunc: "trunc", sin: "sin", cos: "cos", tan: "tan", + asin: "asin", acos: "acos", atan: "atan", sinh: "sinh", cosh: "cosh", tanh: "tanh", asinh: "asinh", acosh: "acosh", + atanh: "atanh", exp: "exp", log: "log", + log2: "log2", sign: "sign", fround: "", +}; + +/** + * JS semantics where WGSL's differ for finite values. Where they differ only for NaN or infinite inputs or results, + * WGSL leaves the result indeterminate and its own operation is used. + */ +const helpers = ` +var nanBits: u32 = 0x7fc00000u; +var infinityBits: u32 = 0x7f800000u; +fn toUint32(x: f32) -> u32 { + let t = abs(trunc(x)); + let wrapped = u32(t - 4294967296.0 * floor(t / 4294967296.0)); + return select(wrapped, 0u - wrapped, x < 0.0); +} +fn power(a: f32, b: f32) -> f32 { + if (b == 0.0) { return 1.0; } + if (a >= 0.0) { return pow(a, b); } + let magnitude = pow(-a, b); + return select(magnitude, -magnitude, fract(b * 0.5) != 0.0); +} +fn roundHalfUp(x: f32) -> f32 { let down = floor(x); return select(down, down + 1.0, x - down >= 0.5); } +`; + +/** + * Generates a compute shader for a kernel program, or the reason it can't run on the GPU: lookups, loops, f64 or + * entity columns, or byte stores. + */ +export const generateWgsl = (program: Program, columns: readonly WasmColumn[], scalarTypes: (scalar: WasmScalar) => unknown, upColumns: readonly WasmColumn[] = []): WgslKernel | string => { + /** + * Reading `up`, a dispatch's record has, after the columns, the word addresses of its rows and their parents' rows, + * whether they have a parent, and each parent column's address; the scalars follow. + */ + const upMode = program.up === true; + const upWords = upMode ? 3 + upColumns.length : 0; + const upWord = (index: number): string => `parameters[${1 + columns.length + index}]`; + if ((program.reductions?.length ?? 0) > 0) return "it uses reductions"; + const scalars: WasmScalar[] = []; + const scalarIndex = new Map(); + const scalarOf = (scalar: WasmScalar): number => { + const key = JSON.stringify(scalar); + let index = scalarIndex.get(key); + if (index === undefined) { + if (scalarTypes(scalar) === null) return fail("a resource or argument value isn't a number or boolean"); + index = scalars.length; + scalars.push(scalar); + scalarIndex.set(key, index); + } + return index; + }; + /** The parameter word holding column `k`'s word address. */ + const base = (column: number): string => `parameters[${1 + column}]`; + /** Word index of a 4-byte aligned element of column `k` in the current row. */ + const word = (column: number): string => { + const { stride, offset } = columns[column]; + if (stride % 4 !== 0 || offset % 4 !== 0) return fail("a component isn't laid out in 4-byte words"); + return `${base(column)} + row * ${stride / 4}u + ${offset / 4}u`; + }; + + /** A 4-byte element of a column, of the row's or, for `up`, of its parent's, at `index`. */ + const readElement = ({ view, boolean }: WasmColumn, address: () => string): Value => { + if (view === "u8") return fail("it reads a byte value, such as a boolean in the CPU layout; give the component a \"storage\" layout"); + const element = `memory[${address()}]`; + if (boolean) return bool(`${element} != 0u`); + switch (view) { + case "f32": return f32(`bitcast(${element})`); + case "u32": return u32(element); + case "i32": return i32(`bitcast(${element})`); + default: return fail("it reads an f64 value, such as an entity or a precision-2 number"); + } + }; + const read = (column: number): Value => readElement(columns[column], () => word(column)); + const readUp = (column: number): Value => { + const { stride, offset } = upColumns[column]; + return readElement(upColumns[column], () => stride % 4 !== 0 || offset % 4 !== 0 ? fail("a component isn't laid out in 4-byte words") : `${upWord(3 + column)} + parent * ${stride / 4}u + ${offset / 4}u`); + }; + + const store = (column: number, value: Value): string => { + const { view, boolean, muted } = columns[column]; + if (muted === true) return ""; + if (boolean) return fail("it writes a boolean component"); + const target = `memory[${word(column)}]`; + switch (view) { + case "f32": return `${target} = bitcast(${asNumber(value)});`; + case "u32": case "i32": return `${target} = ${asUint(value)};`; + default: return fail("it writes an f64 or byte value"); + } + }; + + const shift = (value: Value): string => `(${asUint(value)} & 31u)`; + const localName = (local: number): string => identifier(program.names?.[local] ?? `l_${local}`); + const regions = new Map(regionsOf(program.body).map(region => [region.id, region])); + const regionName = (region: Region): string => identifier(`${region.name}_${region.id}`); + + /** Each local's type: an integer type when every assignment has it, else f32 (booleans as 0 or 1). */ + const localKinds: Kind[] = new Array(program.locals).fill("f32"); + const assigned: (Kind | undefined)[] = new Array(program.locals); + const kindOf = (expression: Expression): Kind => { + switch (expression.kind) { + case "column": case "upLeaf": { + const column = expression.kind === "upLeaf" ? upColumns[expression.column] : columns[expression.column]; + return column === undefined ? "f32" : column.boolean ? "bool" : column.view === "u32" ? "u32" : column.view === "i32" ? "i32" : "f32"; + } + case "upFound": return "bool"; + case "local": return localKinds[expression.local]; + case "unary": return expression.operator === "!" ? "bool" : expression.operator === "~" ? "i32" : "f32"; + case "binary": + switch (expression.operator) { + case "&": case "|": case "^": case "<<": case ">>": return "i32"; + case ">>>": return "u32"; + case "<": case ">": case "<=": case ">=": case "==": case "!=": case "===": case "!==": return "bool"; + default: return "f32"; + } + case "logical": case "conditional": { + const [a, b] = expression.kind === "logical" ? [expression.left, expression.right] : [expression.consequent, expression.alternate]; + const left = kindOf(a); + return left === kindOf(b) ? left : "f32"; + } + case "math": return expression.name === "clz32" ? "u32" : expression.name === "imul" ? "i32" : "f32"; + case "lane": { + const region = regions.get(expression.intrinsic); + return region === undefined ? "f32" : elementKind(region.intrinsic.result); + } + case "boolean": return "bool"; + default: return "f32"; + } + }; + const infer = (body: readonly Statement[]): void => { + for (const statement of body) { + if (statement.kind === "set") { + const kind = kindOf(statement.value); + const current = assigned[statement.local]; + assigned[statement.local] = current === undefined || current === kind ? kind : "f32"; + } + else if (statement.kind === "if") { + infer(statement.consequent); + infer(statement.alternate); + } + } + }; + for (let pass = 0; pass <= program.locals; pass++) { + assigned.fill(undefined); + infer(program.body); + let changed = false; + assigned.forEach((kind, local) => { + const next = kind === "i32" || kind === "u32" ? kind : "f32"; + if (next !== localKinds[local]) { + localKinds[local] = next; + changed = true; + } + }); + if (!changed) break; + } + + /** Compares integers of one type exactly, and anything else as numbers. */ + const compare = (operator: string, left: Value, right: Value): Value => { + const integer = left.type === "i32" || left.type === "u32" ? left.type : right.type === "i32" || right.type === "u32" ? right.type : null; + const fits = (value: Value): boolean => value.type === integer + || (value.literal !== undefined && Number.isInteger(value.literal) && (integer === "u32" ? value.literal >= 0 && value.literal <= 0xFFFFFFFF : Math.abs(value.literal) <= 0x7FFFFFFF)); + if (integer !== null && fits(left) && fits(right)) { + const as = (value: Value): string => integer === "u32" ? asUint(value) : asInt(value); + return bool(`(${as(left)} ${operator} ${as(right)})`); + } + return bool(`(${asNumber(left)} ${operator} ${asNumber(right)})`); + }; + + const emit = (expression: Expression): Value => { + switch (expression.kind) { + case "number": return { code: literal(expression.value), type: "f32", literal: expression.value }; + case "boolean": return bool(String(expression.value)); + case "column": return read(expression.column); + case "upLeaf": return readUp(expression.column); + case "upFound": return bool(`(${upWord(2)} != 0u)`); + case "resource": case "args": { + const scalar: WasmScalar = expression.kind === "args" ? { source: "args", path: expression.path } : { source: "resource", index: expression.index, path: expression.path }; + return f32(`bitcast(parameters[${1 + columns.length + upWords + scalarOf(scalar)}])`); + } + case "local": return { code: localName(expression.local), type: localKinds[expression.local] }; + case "unary": { + const operand = emit(expression.operand); + switch (expression.operator) { + case "-": return f32(`(-${asNumber(operand)})`); + case "+": return f32(asNumber(operand)); + case "!": return bool(`(!${asBool(operand)})`); + case "~": return i32(`(~${asInt(operand)})`); + } + return fail("it uses an unsupported operator"); + } + case "binary": { + const left = emit(expression.left); + const right = emit(expression.right); + const a = asNumber(left); + const b = asNumber(right); + switch (expression.operator) { + case "+": case "-": case "*": case "/": return f32(`(${a} ${expression.operator} ${b})`); + case "%": return f32(`(${a} % ${b})`); + case "**": return f32(`power(${a}, ${b})`); + case "<": case ">": case "<=": case ">=": return compare(expression.operator, left, right); + case "==": case "===": return compare("==", left, right); + case "!=": case "!==": return compare("!=", left, right); + case "&": case "|": case "^": return i32(`(${asInt(left)} ${expression.operator} ${asInt(right)})`); + case "<<": return i32(`(${asInt(left)} << ${shift(right)})`); + case ">>": return i32(`(${asInt(left)} >> ${shift(right)})`); + case ">>>": return u32(`(${asUint(left)} >> ${shift(right)})`); + } + return fail("it uses an unsupported operator"); + } + case "logical": { + const left = emit(expression.left); + const right = emit(expression.right); + if (left.type === "bool" && right.type === "bool") return bool(`(${left.code} ${expression.operator} ${right.code})`); + const same = left.type === right.type && left.type !== "bool"; + const a = same ? left.code : asNumber(left); + const b = same ? right.code : asNumber(right); + const type = same ? left.type : "f32"; + // JS returns an operand: `a && b` is `b` when `a` is truthy, `a || b` is `a` when it is. + return { code: expression.operator === "&&" ? `select(${a}, ${b}, ${asBool(left)})` : `select(${b}, ${a}, ${asBool(left)})`, type }; + } + case "conditional": { + const test = asBool(emit(expression.test)); + const consequent = emit(expression.consequent); + const alternate = emit(expression.alternate); + if (consequent.type === alternate.type) return { code: `select(${alternate.code}, ${consequent.code}, ${test})`, type: consequent.type }; + return f32(`select(${asNumber(alternate)}, ${asNumber(consequent)}, ${test})`); + } + case "math": { + const values = expression.arguments.map(emit); + const items = values.map(asNumber); + const name = expression.name; + if (values.length === 1 && name === "clz32") return u32(`countLeadingZeros(${asUint(values[0])})`); + if (values.length === 2 && name === "imul") return i32(`(${asInt(values[0])} * ${asInt(values[1])})`); + if (items.length === 1 && unaryMath[name] !== undefined) return f32(unaryMath[name] === "" ? items[0] : `${unaryMath[name]}(${items[0]})`); + if (items.length === 1 && name === "round") return f32(`roundHalfUp(${items[0]})`); + if (items.length === 1 && name === "log10") return f32(`(log(${items[0]}) * 0.4342944819032518)`); + if (items.length === 1 && name === "cbrt") return f32(`(sign(${items[0]}) * pow(abs(${items[0]}), 1.0 / 3.0))`); + if (items.length === 2 && (name === "min" || name === "max")) return f32(`${name}(${items[0]}, ${items[1]})`); + if (items.length === 2 && name === "atan2") return f32(`atan2(${items[0]}, ${items[1]})`); + if (items.length === 2 && name === "pow") return f32(`power(${items[0]}, ${items[1]})`); + if (items.length === 2 && name === "hypot") return f32(`length(vec2f(${items[0]}, ${items[1]}))`); + return fail(`it uses Math.${name}, which GPU kernels don't support`); + } + case "lane": { + const region = regions.get(expression.intrinsic); + if (region === undefined) return emit(expression.value); + const code = laneOf(regionName(region), region.intrinsic.result, expression.lane); + return { code, type: elementKind(region.intrinsic.result) }; + } + default: return fail("it uses lookups"); + } + }; + + /** An intrinsic's operand: an earlier region's whole result, or a value built from its lanes. */ + const operand = (lanes: readonly Expression[], type: Program.ValueType): string => { + const [first] = lanes; + const source = first?.kind === "lane" ? regions.get(first.intrinsic) : undefined; + if (source !== undefined && source.intrinsic.result === type + && lanes.every((lane, index) => lane.kind === "lane" && lane.intrinsic === source.id && lane.lane === index)) { + return regionName(source); + } + const kind = elementKind(type); + const items = lanes.map(lane => { + const value = emit(lane); + return kind === "i32" ? asInt(value) : kind === "u32" ? asUint(value) : asNumber(value); + }); + return laneCount(type) === 1 ? items[0] ?? "" : `${type}(${items.join(", ")})`; + }; + + const intrinsicCall = (region: Region): string => { + const { operation, form, parameters } = region.intrinsic; + const operands = region.operands.map((lanes, index) => operand(lanes, parameters[index] ?? "f32")); + switch (form) { + case "infix": return `(${operands.join(` ${operation} `)})`; + case "prefix": return `(${operation}${operands[0] ?? ""})`; + case "call": return `${operation}(${operands.join(", ")})`; + } + }; + + const statements = (body: readonly Statement[], indent: string): string => body.map(statement => { + switch (statement.kind) { + case "set": { + const value = emit(statement.value); + const kind = localKinds[statement.local]; + return `${indent}${localName(statement.local)} = ${kind === "u32" ? asUint(value) : kind === "i32" ? asInt(value) : asNumber(value)};`; + } + case "store": return `${indent}${store(statement.column, emit(statement.value))}`; + // The CPU stamps the whole column once per dispatch instead. + case "stamp": return ""; + case "if": + return `${indent}if (${asBool(emit(statement.test))}) {\n${statements(statement.consequent, `${indent} `)}\n${indent}} else {\n${statements(statement.alternate, `${indent} `)}\n${indent}}`; + case "next": return `${indent}return;`; + case "intrinsic": return `${indent}${regionName(statement)} = ${intrinsicCall(statement)};`; + default: return fail("it uses loops, labeled blocks or lookups"); + } + }).filter(line => line.trim() !== "").join("\n"); + + try { + const body = statements(program.body, " "); + const zero: { readonly [kind: string]: string } = { f32: "0.0", i32: "0i", u32: "0u" }; + const used = usedLocals(program.body); + const locals = [ + ...localKinds.flatMap((kind, local) => used.has(local) ? [` var ${localName(local)}: ${kind} = ${zero[kind]};`] : []), + ...[...regions.values()].map(region => ` var ${regionName(region)}: ${region.intrinsic.result};`), + ].join("\n"); + const code = `@group(0) @binding(0) var memory: array; +@group(0) @binding(1) var parameters: array; +${helpers} +fn kernelRow(row: u32${upMode ? ", parent: u32" : ""}) { +${locals} +${body} +} + +@compute @workgroup_size(${workgroupSize}) +fn main(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) groups: vec3u) { + let ${upMode ? "slot" : "row"} = id.x + id.y * groups.x * ${workgroupSize}u; + if (${upMode ? "slot" : "row"} >= parameters[0]) { return; } + ${upMode ? `kernelRow(memory[${upWord(0)} + slot], memory[${upWord(1)} + slot]);` : "kernelRow(row);"} +} +`; + return { code, scalars }; + } + catch (error) { + if (isUnsupported(error)) return error.unsupported; + throw error; + } +}; diff --git a/packages/ecs/src/implementation/world/worker-platform.ts b/packages/ecs/src/implementation/world/worker-platform.ts new file mode 100644 index 000000000..42ada9a9c --- /dev/null +++ b/packages/ecs/src/implementation/world/worker-platform.ts @@ -0,0 +1,105 @@ +/// +import type { MessagePort as NodeMessagePort, Worker as NodeWorker } from 'node:worker_threads'; +import type { FromWorker, ToWorker } from './protocol.js'; +import { createDeath, lentPort, serveLent, type Endpoint, type Lent, type Port } from './port.js'; + +/** A pool worker as the world's thread sees it. */ +export type WorkerPort = Port; + +/** Where a pool's workers come from: the platform's own, or ones another thread lent. */ +export type WorkerPlatform = { + /** The pool size the `workers` resource's `null` resolves to. */ + readonly count: number; + /** Starts a worker running `worker.js`, or returns null once there are no more to start. */ + readonly start: () => WorkerPort | null; +}; + +/** The pool size for a platform's hardware threads: one worker per thread but the world's. */ +const countFor = (parallelism: number): number => Math.max(1, parallelism - 1); + +const noTransfer: ArrayBuffer[] = []; + +/** The worker's file beside this module's chunk, named apart from `new Worker(...)` so browser builds don't bundle it. */ +const workerFile = "./worker.js"; + +const browserWorker = (): Worker => new Worker(new URL("./worker.js", import.meta.url), { type: "module" }); + +/** A browser `Worker` as a port. Its death is an `error`, such as failing to load, or a message it can't read. */ +export const browserPort = (worker: Worker): WorkerPort => { + const death = createDeath(); + worker.onerror = event => death.die(new Error(`A world worker failed: ${event.message || "it couldn't load"}.`)); + worker.onmessageerror = () => death.die(new Error("A world worker sent a message that couldn't be read.")); + return { + post: (message, transfer = noTransfer) => worker.postMessage(message, transfer), + onMessage: handler => { worker.onmessage = (event: MessageEvent) => handler(event.data); }, + onDeath: death.onDeath, + terminate: async () => worker.terminate(), + }; +}; + +/** A node `worker_threads` worker as a port. Its death is an `error`, such as failing to load, or its exit. */ +export const nodePort = (worker: NodeWorker): WorkerPort => { + const death = createDeath(); + worker.on("error", error => death.die(new Error(`A world worker failed: ${error instanceof Error ? error.message : String(error)}`))); + worker.on("messageerror", () => death.die(new Error("A world worker sent a message that couldn't be read."))); + worker.on("exit", code => death.die(new Error(`A world worker exited with code ${code}.`))); + return { + post: (message, transfer = noTransfer) => worker.postMessage(message, transfer), + onMessage: handler => { worker.on("message", handler); }, + onDeath: death.onDeath, + terminate: async () => { await worker.terminate(); }, + }; +}; + +/** `worker_threads` on node, loaded only there, so browser bundles never load it. */ +const nodeThreads = () => globalThis.process?.getBuiltinModule?.("node:worker_threads") ?? null; + +/** + * The platform's workers, nested workers where the world is on a worker, or null where there are none: browser + * workers where `Worker` exists, and `worker_threads` on node. Counts come from `navigator.hardwareConcurrency` or + * `os.availableParallelism()`. + */ +export const platformWorkers = (): WorkerPlatform | null => { + if (typeof Worker === "function") { + return { count: countFor(globalThis.navigator?.hardwareConcurrency ?? 2), start: () => browserPort(browserWorker()) }; + } + const threads = nodeThreads(); + if (threads === null) return null; + const parallelism = globalThis.process.getBuiltinModule("node:os").availableParallelism(); + return { count: countFor(parallelism), start: () => nodePort(new threads.Worker(new URL(workerFile, import.meta.url))) }; +}; + +/** Workers another thread lent, each started once: the pool takes as many as there are. */ +export const lentWorkers = (lent: readonly Lent[]): WorkerPlatform => { + let next = 0; + return { count: lent.length, start: () => next < lent.length ? lentPort(lent[next++]) : null }; +}; + +/** A worker lent to another thread: what to transfer there, in the platform's `MessagePort`s, and ending it from here. */ +export type Lending

= { readonly lent: Lent

; readonly terminate: () => Promise }; + +/** + * On the thread owning them: starts `count` workers to lend to a world on another thread, browser workers here. Each + * gets the port it talks on before anything else; this thread keeps the worker, forwards its death to the world's thread + * and ends it when asked. Post each `lent` there, transferring its two ports; there, `lentWorkers` makes them a platform. + */ +export const lendBrowserWorkers = (count: number): readonly Lending[] => Array.from({ length: count }, () => { + const worker = browserWorker(); + const data = new MessageChannel(); + const control = new MessageChannel(); + worker.postMessage({ kind: "port", port: data.port1 } satisfies ToWorker, [data.port1]); + return { lent: { port: data.port2, owner: control.port2 }, terminate: serveLent(browserPort(worker), control.port1).terminate }; +}); + +/** `lendBrowserWorkers` for node's `worker_threads`, or none where they're missing. */ +export const lendNodeWorkers = (count: number): readonly Lending[] => { + const threads = nodeThreads(); + if (threads === null) return []; + return Array.from({ length: count }, () => { + const worker = new threads.Worker(new URL(workerFile, import.meta.url)); + const data = new threads.MessageChannel(); + const control = new threads.MessageChannel(); + worker.postMessage({ kind: "port", port: data.port1 } satisfies ToWorker, [data.port1]); + return { lent: { port: data.port2, owner: control.port2 }, terminate: serveLent(nodePort(worker), control.port1).terminate }; + }); +}; diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts new file mode 100644 index 000000000..960d45be7 --- /dev/null +++ b/packages/ecs/src/implementation/world/worker.ts @@ -0,0 +1,560 @@ +import { buildKernel, buildViews, phases, readPlan, runBuildStep } from './index-build.js'; +import type { Entity } from '../../interface/entity/entity.js'; +import { createBlock, endBlock, locate, reserveFrom, tableLength, type Block } from './entities.js'; +import { createBatch, positionalSpawner, type Positional, type SpawnBatch } from './staging.js'; +import type { Kernel, KernelCommands, KernelModule } from './kernel.js'; +import { layoutOf, viewsOf, type Layout, type TypedArray, type Views } from '../../interface/registry/layout.js'; +import { emptyViews } from './arrays.js'; +import { claimPiece } from './claims.js'; +import { controlWords, done, epoch, idleKernel, lineWords, posted, ringBase, ringCapacity, ringCursor, ringNeeded, ringSize, signal, taskFields, taskWords, type FromWorker, type KernelSetup, type LookupSetup, type ToWorker, type WorkerError } from './protocol.js'; +import { beginCommand, createRingWriter, encoderOf, endSegment, noExtent, objectStart, putComponent, putStart, putValue, startSegment, type Encoder, type Extent } from './command-ring.js'; +import { forEachPassingRange, type Filter } from './ranges.js'; +import { indexParameterCount, instantiateWasm, scalarValues, type WasmRun } from './wasm.js'; +import { createIndexReader, createIndexSource, sourceFromParameters, type IndexReader } from './index-readers.js'; +import { coveringBuffer, pageBytes, pagesWord } from './memory.js'; +import { endpointChannel, scopeChannel } from './port.js'; +import { + archetypeField, capacityField, cursorField, entitiesVersionField, entitiesWord, freeCapacityField, freeField, generationField, linearFlag, + recordCapacity, recordEntities, recordStorages, recordVersion, recordWords, rowField, storageAdded, storageChanged, storageChunkAdded, + storageChunkChanged, storageChunks, storageColumn, storageComponent, storageFlags, storageWords, tableLengthWord, tableOffsetWord, tickedFlag, +} from './layout-directory.js'; + +/** One storage's views in a worker. */ +type StorageViews = { + readonly views: Views; + readonly added: Float64Array; + readonly changed: Float64Array; + readonly chunkAdded: Float64Array; + readonly chunkChanged: Float64Array; +}; + +/** A worker's views of one archetype's columns, as of its record's `version`. */ +type ArchetypeViews = { + readonly version: number; + readonly entities: Float64Array; + readonly storages: ReadonlyMap; +}; + +type EntityViews = { cursor: Int32Array; free: Int32Array; generation: Uint32Array; archetype: Int32Array; row: Uint32Array; length: number }; + +/** A kernel's columns, change filters and their WASM addresses in one archetype, as of `views`. */ +type Bound = { + readonly views: ArchetypeViews; + readonly columns: readonly TypedArray[]; + readonly filters: readonly Filter[]; + /** Column addresses, then filters' tick and chunk addresses: a WASM kernel's parameters after its first four. */ + readonly addresses: readonly number[]; +}; + +type LoadedKernel = { + readonly setup: KernelSetup; + readonly kernel: Kernel; + readonly wasm: WasmRun | null; + /** By lookup: a function by entity, or for a lookup through an index its reader, pointed at the index by each task. */ + readonly lookups: readonly unknown[]; + /** What its lookups by entity with `changed` compare with, set by each task; null for a kernel without them. */ + readonly since: Since | null; + /** Its lookups through indexes' readers, in lookup order, with the parameters each takes. */ + readonly readers: readonly { readonly reader: IndexReader; readonly watched: number; readonly count: number }[]; + /** + * Where its spawns are staged, one batch per set of components its lists name, with its spawners, and its commands, + * which write into this worker's command ring; made on first run, once its components' layouts are known. + */ + staging: { readonly batches: readonly SpawnBatch[]; readonly spawners: readonly Positional[]; readonly commands: KernelCommands } | null; + /** By archetype id, what it ran over there, rebuilt when the archetype's record moves on. */ + readonly bound: (Bound | undefined)[]; +}; + +let memory: WebAssembly.Memory | null = null; +/** + * The memory's buffer, a view of it as words, for the layout directory, and views of all of it, where the command ring + * is; rebuilt when the memory grows. + */ +let memoryBuffer: ArrayBufferLike = new ArrayBuffer(0); +let words = new Uint32Array(0); +let memoryViews: Views = emptyViews; +/** This worker's control words, its command ring's among them, and where its tasks write their segments. */ +let control = new Int32Array(0); +const writer = createRingWriter(); +/** By component id, how its values are written into the ring. */ +const encoders: Encoder[] = []; +/** The memory's length in pages that `buffer` covers, and the header's first word. */ +let pages = -1; +let header = 0; +const layouts: Layout[] = []; +/** By archetype id: its record's first word, once found in the table, and its views. */ +const records: (number | undefined)[] = []; +const archetypes: (ArchetypeViews | undefined)[] = []; +let entities: EntityViews | null = null; +let entitiesVersion = -1; +const kernels = new Map(); + +const required = (value: T | null, what: string): T => { + if (value === null) throw new Error(`Worker received a task before its ${what}.`); + return value; +}; + +const errorOf = (error: unknown): WorkerError => + error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : { name: "Error", message: String(error) }; + +/** Once views may be stale, every later task fails rather than touch memory. */ +let broken: WorkerError | null = null; + +/** + * At each task's start: rebuilds the whole-buffer view once the memory grew, and marks the worker broken if its buffer + * doesn't cover the length the world stored, since typed arrays read out of bounds as `undefined` and drop such writes. + */ +const refreshMemory = (): void => { + if (broken !== null) throw new Error(`Worker can't run tasks after its layout failed: ${broken.message}`); + const shared = required(memory, "memory"); + const current = Atomics.load(words, header + pagesWord); + if (current === pages) return; + const latest = coveringBuffer(shared, current * pageBytes); + if (latest.byteLength < current * pageBytes) { + broken = { name: "Error", message: `The worker's memory is ${latest.byteLength} bytes, short of the ${current * pageBytes} the world stored.` }; + throw new Error(broken.message); + } + memoryBuffer = latest; + words = new Uint32Array(latest); + memoryViews = viewsOf(latest, 0, latest.byteLength); + pages = current; +}; + +/** The entity table, its views updated in place when its record moves on, since spawners hold it. */ +const refreshEntities = (): EntityViews => { + const record = header + entitiesWord; + const version = Atomics.load(words, record + entitiesVersionField); + if (entities === null || version !== entitiesVersion) { + const capacity = words[record + capacityField]; + const views: EntityViews = { + cursor: new Int32Array(memoryBuffer, words[record + cursorField], 4), + free: new Int32Array(memoryBuffer, words[record + freeField], words[record + freeCapacityField]), + archetype: new Int32Array(memoryBuffer, words[record + archetypeField], capacity), + row: new Uint32Array(memoryBuffer, words[record + rowField], capacity), + generation: new Uint32Array(memoryBuffer, words[record + generationField], capacity), + length: 0, + }; + if (entities === null) entities = views; + else Object.assign(entities, views); + entitiesVersion = version; + } + entities.length = entities.cursor[tableLength]; + return entities; +}; + +/** The tick arrays of a component without ticks. */ +const noTicks = new Float64Array(0); + +const viewArchetype = (record: number, version: number): ArchetypeViews => { + const capacity = words[record + recordCapacity]; + const storages = new Map(); + for (let s = 0; s < words[record + recordStorages]; s++) { + const base = record + recordWords + s * storageWords; + const component = words[base + storageComponent]; + const flags = words[base + storageFlags]; + const layout = layouts[component]; + const ticked = (flags & tickedFlag) !== 0; + storages.set(component, { + views: layout.kind === "linear" && (flags & linearFlag) !== 0 ? viewsOf(memoryBuffer, words[base + storageColumn], capacity * layout.stride) : emptyViews, + added: ticked ? new Float64Array(memoryBuffer, words[base + storageAdded], capacity) : noTicks, + changed: ticked ? new Float64Array(memoryBuffer, words[base + storageChanged], capacity) : noTicks, + chunkAdded: ticked ? new Float64Array(memoryBuffer, words[base + storageChunkAdded], words[base + storageChunks]) : noTicks, + chunkChanged: ticked ? new Float64Array(memoryBuffer, words[base + storageChunkChanged], words[base + storageChunks]) : noTicks, + }); + } + return { version, entities: new Float64Array(memoryBuffer, words[record + recordEntities], capacity), storages }; +}; + +/** An archetype's views: one load of its record's version, and new views only once it moved on. */ +const archetypeAt = (id: number): ArchetypeViews | undefined => { + let record = records[id]; + if (record === undefined) { + // Records never move, so each is found in the table once. + const offset = id < Atomics.load(words, header + tableLengthWord) ? words[(words[header + tableOffsetWord] >> 2) + id] : 0; + if (offset === 0) return undefined; + record = records[id] = offset >> 2; + } + const version = Atomics.load(words, record + recordVersion); + const cached = archetypes[id]; + if (cached !== undefined && cached.version === version) return cached; + return archetypes[id] = viewArchetype(record, version); +}; + +const requiredArchetype = (id: number): ArchetypeViews => { + const archetype = archetypeAt(id); + if (archetype === undefined) throw new Error(`Worker has no layout for archetype ${id}.`); + return archetype; +}; + +/** A JS kernel's lookups by entity with `changed` compare with this: the system's `lastRun`, as each task's lookup parameters end with it. */ +type Since = { value: number }; + +/** + * A JS kernel's lookup by entity with `changed`: one array reused, `read` values then whether a component it watches + * may have changed after `since`; a component keeping no ticks counts as changed. + */ +const createChangedLookup = (lookup: LookupSetup, watched: readonly number[], since: Since) => { + const values = new Array(lookup.read.length + 1); + return (entity: Entity): readonly unknown[] | null => { + const table = required(entities, "entity table"); + const index = locate(table, entity); + if (index < 0) return null; + const archetype = archetypeAt(table.archetype[index]); + if (archetype === undefined) return null; + for (const id of lookup.read) if (!archetype.storages.has(id)) return null; + for (const id of lookup.has) if (!archetype.storages.has(id)) return null; + for (const id of lookup.not) if (archetype.storages.has(id)) return null; + const row = table.row[index]; + const { read } = lookup; + for (let i = 0; i < read.length; i++) { + const layout = layouts[read[i]]; + const storage = archetype.storages.get(read[i]); + values[i] = layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; + } + let changed = since.value < 0; + for (let i = 0; i < watched.length && !changed; i++) { + const ticks = archetype.storages.get(watched[i])?.changed; + changed = ticks === undefined || ticks.length === 0 || ticks[row] > since.value; + } + values[read.length] = changed; + return values; + }; +}; + +const createLookup = (lookup: LookupSetup) => (entity: Entity): readonly unknown[] | null => { + const table = required(entities, "entity table"); + const index = locate(table, entity); + if (index < 0) return null; + const archetype = archetypeAt(table.archetype[index]); + if (archetype === undefined) return null; + for (const id of lookup.read) if (!archetype.storages.has(id)) return null; + for (const id of lookup.has) if (!archetype.storages.has(id)) return null; + for (const id of lookup.not) if (archetype.storages.has(id)) return null; + const row = table.row[index]; + return lookup.read.map(id => { + const layout = layouts[id]; + const storage = archetype.storages.get(id); + return layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; + }); +}; + +/** An archetype as an index reader reads its rows: its entities and columns, from the layout directory. */ +const readerArchetype = (id: number) => { + const archetype = archetypeAt(id); + return archetype === undefined ? undefined : { version: archetype.version, entities: archetype.entities, column: (component: number, view: keyof Views) => archetype.storages.get(component)?.views[view] }; +}; + +/** The one element a muted tick column's stores land on. */ +const mutedColumn = new Float64Array(1); + +/** A kernel's change filters in an archetype: `added` then `changed`, rows and chunks. */ +const filtersOf = (loaded: LoadedKernel, archetype: ArchetypeViews): Filter[] => [ + ...loaded.setup.added.flatMap(id => { + const storage = archetype.storages.get(id); + return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded }]; + }), + ...loaded.setup.changed.flatMap(id => { + const storage = archetype.storages.get(id); + return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged }]; + }), +]; + +const bind = (loaded: LoadedKernel, archetypeId: number, archetype: ArchetypeViews): Bound => { + const columns = loaded.setup.sources.map((source, k) => { + if (loaded.setup.muted[k]) return mutedColumn; + if (source.kind === "entity") return archetype.entities; + const storage = archetype.storages.get(source.id); + if (storage === undefined) throw new Error(`Archetype ${archetypeId} lacks component ${source.id}.`); + if (source.kind === "changed") return storage.changed; + if (source.kind === "chunks") return storage.chunkChanged; + const layout = layouts[source.id]; + if (layout.kind !== "linear") throw new Error(`Component ${source.id} isn't linear.`); + return storage.views[layout.leaves[source.leaf].view]; + }); + const filters = filtersOf(loaded, archetype); + const addresses = [...columns.map(column => column.byteOffset), ...filters.map(filter => filter.ticks.byteOffset), ...filters.map(filter => filter.chunks.byteOffset)]; + return { views: archetype, columns, filters, addresses }; +}; + +/** What a kernel runs over in an archetype, kept until the archetype's record moves on. */ +const boundOf = (loaded: LoadedKernel, archetypeId: number): Bound => { + const archetype = requiredArchetype(archetypeId); + const cached = loaded.bound[archetypeId]; + if (cached !== undefined && cached.views === archetype) return cached; + return loaded.bound[archetypeId] = bind(loaded, archetypeId, archetype); +}; + +type RunMessage = Extract; + +const noPartials = new Float64Array(0); + +/** This worker's reservations for kernel spawns, in blocks, so workers spawning together don't contend; ended by each task. */ +let block: Block | null = null; + +const encoderFor = (id: number): Encoder => encoders[id] ??= encoderOf(layouts[id]); + +/** A kernel's staging and commands, made once its components' layouts are known. */ +const stagingOf = (loaded: LoadedKernel): NonNullable => { + if (loaded.staging !== null) return loaded.staging; + const reserving = block ??= createBlock(required(entities, "entity table")); + const batches: SpawnBatch[] = []; + const { keys, starts } = loaded.setup; + const spawners = loaded.setup.spawns.map(ids => { + const key = [...ids].sort((a, b) => a - b).join(","); + let batch = batches.find(candidate => candidate.ids.join(",") === key); + if (batch === undefined) batches.push(batch = createBatch(layouts, ids)); + // An object's or a string's start is the world's to resolve. + return positionalSpawner(reserving, reserveFrom, batch, ids, ids.map(id => layouts[id].kind === "object" || layouts[id].kind === "string" ? objectStart : starts[keys.indexOf(id)])); + }); + const valueEncoders = keys.map(encoderFor); + const commands: KernelCommands = { + spawners, + begin: (kind, entity) => beginCommand(writer, kind, entity), + value: (key, value) => value === undefined ? putStart(writer, keys[key]) : putValue(writer, keys[key], valueEncoders[key], value), + component: key => putComponent(writer, keys[key]), + }; + return loaded.staging = { batches, spawners, commands }; +}; + +/** Reservation positions this worker's block left unused, reused by each task. */ +const unused: number[] = []; + +const noBatches: readonly SpawnBatch[] = []; + +/** Writes a run's segment: its commands, its staged spawn batches, in batch order, emptying them, then the reservations its block left unused. */ +const writeSegment = (message: RunMessage): Extent => { + unused.length = 0; + if (block !== null) endBlock(block, unused); + const staging = kernels.get(message.kernel)?.staging ?? null; + return endSegment(writer, memoryViews, Atomics.load(control, ringBase) >>> 0, Atomics.load(control, ringCapacity), Atomics.load(control, ringCursor), staging?.batches ?? noBatches, unused); +}; + +const run = (message: RunMessage): void => { + const loaded = kernels.get(message.kernel); + if (loaded === undefined) throw new Error(`Worker has no kernel ${message.kernel}.`); + refreshEntities(); + const { columns, filters, addresses } = boundOf(loaded, message.archetype); + const { commands } = stagingOf(loaded); + const { wasm } = loaded; + if (wasm !== null && loaded.setup.wasm !== null) { + wasm(message.start, message.end, message.tick, message.lastRun, ...addresses, ...message.lookups, ...message.indexes, + ...scalarValues(loaded.setup.wasm.scalars, message.resources, message.args), + ...message.partials < 0 ? [] : [message.partials]); + return; + } + const partials = message.partials < 0 ? noPartials : new Float64Array(memoryBuffer, message.partials); + // A kernel watching through lookups by entity gets its `lastRun` last among the lookup parameters. + if (loaded.since !== null) loaded.since.value = message.lookups[message.lookups.length - 1] ?? 0; + // Lookups through indexes read the index where the task's parameters say it is now. + let at = 0; + for (const { reader, watched, count } of loaded.readers) { + sourceFromParameters(reader.source, memoryViews, message.indexes, at, watched); + at += count; + } + forEachPassingRange(filters, message.lastRun, message.start, message.end, (start, end) => + loaded.kernel(columns, start, end, message.resources, loaded.lookups, loaded.setup.keys, message.args, commands, message.tick, partials, 0)); +}; + +const load = async (setups: readonly KernelSetup[]): Promise => { + const shared = required(memory, "memory"); + const loaded = await Promise.all(setups.map(async setup => { + const module: KernelModule = await import(/* @vite-ignore */ setup.url); + const wasm = setup.wasm === null ? null : await instantiateWasm(setup.wasm.module, shared, setup.wasm.imports); + return { module, wasm }; + })); + // A kernel loaded again binds its columns again, since they may differ, such as muted ones now stamped. + setups.forEach((setup, i) => { + const readers = setup.indexes.map(({ lookup, reader, watched }) => ({ + lookup, + reader: createIndexReader(reader, createIndexSource(reader.kind), readerArchetype), + watched, + count: indexParameterCount({ kind: reader.kind, words: reader.words, stride: reader.stride, probe: () => null, rowLeaf: () => null, watched }), + })); + const since: Since | null = setup.lookups.some(lookup => lookup.changed !== null) ? { value: 0 } : null; + const lookups = setup.lookups.map((lookup, k): unknown => readers.find(found => found.lookup === k)?.reader + ?? (lookup.changed === null || since === null ? createLookup(lookup) : createChangedLookup(lookup, lookup.changed, since))); + kernels.set(setup.index, { since, setup, kernel: loaded[i].module.create(setup.booleans, setup.strides.map((stride, k) => setup.muted[k] ? 0 : stride), setup.offsets.map((offset, k) => setup.muted[k] ? 0 : offset), setup.up.booleans, setup.up.strides, setup.up.offsets), wasm: loaded[i].wasm, lookups, readers, staging: null, bound: [] }); + }); +}; + +/** A split's claims, as this worker sees them. */ +type Claims = { readonly words: Int32Array; readonly self: number; readonly participants: number }; + +/** + * Runs a loaded kernel's WASM over rows `start` to `end` of an archetype; it applies the change filters itself. + * With claims, runs only the `grain`-row pieces this worker claims. + */ +const runWasm = (loaded: LoadedKernel, archetypeId: number, start: number, end: number, tick: number, lastRun: number, scalars: readonly number[], claims: Claims | null, grain: number, partials: number, partialBytes: number): void => { + const { wasm } = loaded; + if (wasm === null) throw new Error(`Worker kernel ${loaded.setup.index} has no WASM.`); + const parameters = [...boundOf(loaded, archetypeId).addresses, ...scalars]; + if (claims === null) { + if (partials < 0) return void wasm(start, end, tick, lastRun, ...parameters); + // An even share with reductions runs piece by piece, each into its own partials, as claims would. + for (let from = start, piece = 0; from < end; from += grain, piece++) wasm(from, Math.min(end, from + grain), tick, lastRun, ...parameters, partials + piece * partialBytes); + return; + } + const { words, self, participants } = claims; + for (let piece = claimPiece(words, self, participants); piece >= 0; piece = claimPiece(words, self, participants)) { + const from = start + piece * grain; + wasm(from, Math.min(end, from + grain), tick, lastRun, ...parameters, ...partials < 0 ? [] : [partials + piece * partialBytes]); + } +}; + +/** Runs a build step's task (`index-build.ts`): its phase over the pieces or partitions it claims, or its even share. */ +const runBuildTask = (payloads: Float64Array, base: number, buffer: ArrayBufferLike, message: Extract): void => { + const views = buildViews(memoryBuffer); + const plan = readPlan(views.f64, payloads[base + taskFields]); + const phase = phases[payloads[base + 1]]; + const start = payloads[base + 2]; + const end = payloads[base + 3]; + const line = payloads[base + 8]; + const grain = payloads[base + 9]; + if (line < 0) return runBuildStep(views, plan, phase, start, end); + const participants = payloads[base + 11]; + const words = new Int32Array(buffer, message.claims + line * lineWords * 4, participants * lineWords); + const self = payloads[base + 10]; + for (let piece = claimPiece(words, self, participants); piece >= 0; piece = claimPiece(words, self, participants)) { + runBuildStep(views, plan, phase, start + piece * grain, Math.min(end, start + (piece + 1) * grain)); + } +}; + +/** + * Runs tasks from this worker's ring in shared memory, blocking between them, until the main thread asks it to leave; + * then it returns to handling messages. + */ +const runWindow = (message: Extract): void => { + const buffer = required(memory, "memory").buffer; + // The pool's control state never moves, so these views last the window. + const control = new Int32Array(buffer, message.control + message.worker * controlWords * 4, controlWords); + const statuses = new Int32Array(buffer, message.statuses + message.worker * ringSize * 4, ringSize); + const payloads = new Float64Array(buffer, message.payloads + message.worker * ringSize * taskWords * 8, ringSize * taskWords); + let next = Atomics.load(control, done); + for (;;) { + const seen = Atomics.load(control, signal); + if (next < Atomics.load(control, posted)) { + const ring = next % ringSize; + const base = ring * taskWords; + // A task from a later window waits until this worker has handled the messages sent before it. + if (payloads[base + 6] !== message.epoch) return; + let status = next + 1; + try { + refreshMemory(); + const kernel = payloads[base]; + if (kernel === buildKernel) runBuildTask(payloads, base, buffer, message); + else if (kernel !== idleKernel) { + const loaded = kernels.get(kernel); + if (loaded === undefined) throw new Error("Worker can't run this task."); + const line = payloads[base + 8]; + const participants = payloads[base + 11]; + const scalarCount = payloads[base + 7]; + const partials = loaded.setup.reduces ? payloads[base + taskFields + scalarCount] : -1; + const partialBytes = loaded.setup.reduces ? payloads[base + taskFields + scalarCount + 1] : 0; + runWasm(loaded, payloads[base + 1], payloads[base + 2], payloads[base + 3], payloads[base + 4], payloads[base + 5], + Array.from(payloads.subarray(base + taskFields, base + taskFields + scalarCount)), + line < 0 ? null : { words: new Int32Array(buffer, message.claims + line * lineWords * 4, participants * lineWords), self: payloads[base + 10], participants }, + payloads[base + 9], partials ?? -1, partialBytes ?? 0); + } + } + catch { + status = -status; + } + Atomics.store(statuses, ring, status); + next++; + Atomics.store(control, done, next); + Atomics.notify(control, done); + continue; + } + if (Atomics.load(control, epoch) !== message.epoch) return; + // Tiers follow each other closely, so spin briefly before paying for a blocking wait's wake-up. + const deadline = performance.now() + 0.05; + while (Atomics.load(control, signal) === seen && performance.now() < deadline); + if (Atomics.load(control, signal) === seen) Atomics.wait(control, signal, seen); + } +}; + +/** Where messages come from and replies go: this worker's own scope, until a lent worker's owner gives it a port. */ +let channel = scopeChannel(); + +const handle = async (message: ToWorker): Promise => { + if (message.kind === "port") { + channel = endpointChannel(message.port); + channel.onMessage(receive); + return; + } + if (message.kind === "memory") { + memory = message.memory; + header = message.header >> 2; + memoryBuffer = memory.buffer; + words = new Uint32Array(memoryBuffer); + memoryViews = viewsOf(memoryBuffer, 0, memoryBuffer.byteLength); + control = new Int32Array(memoryBuffer, message.control, controlWords); + return; + } + if (message.kind === "window") { + runWindow(message); + return; + } + if (message.kind === "layout") { + try { + for (const [id, schema] of message.schemas) layouts[id] = layoutOf(schema); + } + catch (error) { + broken ??= errorOf(error); + } + return; + } + if (message.kind === "tables") { + channel.post({ kind: "tables", task: message.task, kernels: kernels.size }); + return; + } + if (message.kind === "kernels") { + try { + refreshMemory(); + await load(message.kernels); + channel.post({ kind: "done", task: message.task, start: 0, end: 0, spill: null }); + } + catch (error) { + channel.post({ kind: "failed", task: message.task, start: 0, end: 0, spill: null, error: errorOf(error) }); + } + return; + } + let failure: unknown = undefined; + let segment = false; + try { + refreshMemory(); + startSegment(writer, message); + segment = true; + run(message); + } + catch (error) { + failure = error; + } + // What a failed task issued before failing still applies, its spawns included. + let extent = noExtent; + if (segment) { + try { + extent = writeSegment(message); + } + catch (error) { + failure ??= error; + } + } + const { start, end, spill, transfer: spilled } = extent; + if (extent !== noExtent) { + Atomics.store(control, ringCursor, end); + Atomics.store(control, ringNeeded, Atomics.load(control, ringNeeded) + end - start + (spill?.length ?? 0)); + } + const transfer = spilled === null ? noTransfer : [spilled]; + if (failure === undefined) channel.post({ kind: "done", task: message.task, start, end, spill }, transfer); + else channel.post({ kind: "failed", task: message.task, start, end, spill, error: errorOf(failure) }, transfer); +}; + +const noTransfer: ArrayBuffer[] = []; + +/** Handles messages strictly in order, including across a kernel load's `await`. */ +let handled: Promise = Promise.resolve(); +const receive = (message: ToWorker): void => { + handled = handled.then(() => handle(message)); +}; +channel.onMessage(receive); diff --git a/packages/ecs/src/implementation/world/workers.cases.ts b/packages/ecs/src/implementation/world/workers.cases.ts new file mode 100644 index 000000000..0405570b4 --- /dev/null +++ b/packages/ecs/src/implementation/world/workers.cases.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./workers.fixture.js"; + +/** Kernels on workers give the main thread's results: the cases, over the fixture built for node or from source. */ +export const workersCases = (load: () => Promise) => describe("workers", () => { + it("give the same results as the main thread alone", async () => { + const fixture = await load(); + // JS kernels on the main thread alone, against WASM kernels (where a kernel has one) on workers. + const alone = await fixture.simulate(0, false); + expect(alone.rows.some(row => row.includes("true"))).toBe(true); + const pooled = await fixture.simulate(3); + expect(pooled.rows).toEqual(alone.rows); + // Archetypes of at least 64 rows ran on the workers every frame. + expect(pooled.tasks).toBeGreaterThanOrEqual(12 * 3); + }); + + it("give the same SIMD results as the main thread alone, since chunks start on 16-row boundaries", async () => { + const fixture = await load(); + expect((await fixture.simulate(3, true, true)).rows).toEqual((await fixture.simulate(0, true, true)).rows); + }); + + it("keep tasks in order when a worker's ring overflows, and split evenly once every claim counter is in flight", async () => { + const fixture = await load(); + // One worker gets a task per archetype: 512, eight times its ring and twice the claim counters. + expect(await fixture.scattered(1)).toEqual(await fixture.scattered(0)); + }); + + it("merge chunk commands in the same order as the main thread, whatever the worker count", async () => { + const fixture = await load(); + const alone = await fixture.ordered(0); + for (const count of [1, 2, 7]) expect(await fixture.ordered(count)).toEqual(alone); + }); + + it("stage kernel spawns from every worker and the main thread in chunk order, whatever the worker count", async () => { + const fixture = await load(); + const rows = Array.from({ length: 1_015 }, (_, i) => i); + // Storage order is the spawns' order, row by row over the archetypes, wherever each chunk ran. + const alone = await fixture.spawns(0, false); + expect(alone).toEqual([1_015, rows.map(i => [i, -i]), rows.map(() => null), rows.map(() => true)]); + for (const count of [1, 2, 7]) expect(await fixture.spawns(count, false)).toEqual(alone); + // Spawned entities moved in the same sync point arrive in the order of the commands that moved them. + const edited = await fixture.spawns(0, true); + expect(edited).toEqual([1_015, rows.map(i => [i, -i]), rows.map(i => [i, 1]), rows.map(() => null)]); + for (const count of [1, 2, 7]) expect(await fixture.spawns(count, true)).toEqual(edited); + }); + + it("can't touch the resource entity", async () => { + const fixture = await load(); + expect(await fixture.unlinked()).toBe(0.25); + }); + + it("are terminated by dispose", async () => { + const fixture = await load(); + const { started, terminated, after } = await fixture.disposed(); + expect(started).toBe(2); + expect(terminated).toBe(2); + expect(after).toContain("disposed"); + }); +}); diff --git a/packages/ecs/src/implementation/world/workers.fixture.ts b/packages/ecs/src/implementation/world/workers.fixture.ts new file mode 100644 index 000000000..1a63ad6e2 --- /dev/null +++ b/packages/ecs/src/implementation/world/workers.fixture.ts @@ -0,0 +1,261 @@ +import { Component } from "../../interface/component/component.js"; +import { Entity } from "../../interface/entity/entity.js"; +import { Resource } from "../../interface/resource/resource.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { read, readEach, readResource, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import { workers } from "../../interface/resources/workers.js"; +import { countingWorkers } from "./platforms.test-helper.js"; +import type { WorkerPlatform } from "./worker-platform.js"; + +/** Scenarios of kernels on workers against the main thread alone, run built in node and from source in chromium. */ + +type Vec2 = readonly [number, number]; +const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const satisfies Schema; + +const position = Symbol("workersTest/position"); +const velocity = Symbol("workersTest/velocity"); +const target = Symbol("workersTest/target"); +const offset = Symbol("workersTest/offset"); +const spawned = Symbol("workersTest/spawned"); +const tagA = Symbol("workersTest/tag-a"); +const tagB = Symbol("workersTest/tag-b"); +const tagC = Symbol("workersTest/tag-c"); +const tagD = Symbol("workersTest/tag-d"); +const tagE = Symbol("workersTest/tag-e"); +const tagF = Symbol("workersTest/tag-f"); +const tagG = Symbol("workersTest/tag-g"); +const tagH = Symbol("workersTest/tag-h"); +const tagI = Symbol("workersTest/tag-i"); +const tags = [tagA, tagB, tagC, tagD, tagE, tagF, tagG, tagH, tagI] as const; +const seconds = Symbol("workersTest/seconds"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number] }; + [velocity]: { type: readonly [number, number] }; + [target]: { type: Entity }; + [offset]: { type: number }; + [spawned]: { type: true }; + [tagA]: { type: true }; + [tagB]: { type: true }; + [tagC]: { type: true }; + [tagD]: { type: true }; + [tagE]: { type: true }; + [tagF]: { type: true }; + [tagG]: { type: true }; + [tagH]: { type: true }; + [tagI]: { type: true }; + } + export interface Resources { + [seconds]: { type: number }; + } +} + +Component.register(position, { type: vec2 }); +Component.register(velocity, { type: vec2 }); +Component.register(target, { type: Entity.schema }); +Component.register(offset, { type: { type: "number", precision: 1 } }); +Component.register(spawned, { type: { const: true } }); +Component.register(tagA, { type: { const: true } }); +Component.register(tagB, { type: { const: true } }); +Component.register(tagC, { type: { const: true } }); +Component.register(tagD, { type: { const: true } }); +Component.register(tagE, { type: { const: true } }); +Component.register(tagF, { type: { const: true } }); +Component.register(tagG, { type: { const: true } }); +Component.register(tagH, { type: { const: true } }); +Component.register(tagI, { type: { const: true } }); + +/** 64 entities in each of the 512 archetypes the tags combine into. */ +const scatter = System.create({ name: "workersTest/scatter" }, ({ commands }) => { + for (let i = 0; i < 512 * 64; i++) { + const components: (typeof position | typeof velocity | (typeof tags)[number])[] = [position, velocity]; + tags.forEach((tag, bit) => { if (((i >> 6) & (1 << bit)) !== 0) components.push(tag); }); + commands.spawn(components)({ [position]: [i, 0], [velocity]: [1, i % 5] }); + } +}); +Resource.register(seconds, { type: { type: "number", default: 0.25 } }); + +/** Spawns `count` moving entities, each following the one before it. */ +const seed = System.create({ name: "workersTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + let previous = commands.spawn([position, velocity])({ [position]: [0, 0], [velocity]: [1, 0] }); + for (let i = 1; i < input.count; i++) { + previous = commands.spawn([position, velocity, target])({ [position]: [i, 0], [velocity]: [1, i % 7], [target]: previous }); + } +}); + +const integrate = System.create({ + name: "workersTest/integrate", + kernel: "cpu", + components: { read: [velocity], write: [position] }, + resources: { read: [seconds] }, +}, (row, { resources }) => { + const [x, y] = row[position]; + const [vx, vy] = row[velocity]; + row[position] = [x + vx * resources[seconds], y + vy * resources[seconds]]; +}); + +/** Structural commands from workers: rows past a threshold spawn a marker and lose their velocity. */ +const split = System.create({ + name: "workersTest/split", + kernel: "cpu", + components: { read: [position], has: [velocity] }, + after: [integrate], +}, (row, { commands }) => { + const [x, y] = row[position]; + if (y > 3) { + commands.spawn([position, spawned])({ [position]: [x, -y], [spawned]: true }); + commands.remove(row.entity, velocity); + } +}); + +/** Lookups from workers: offset by the distance to the target. */ +const follow = System.create({ + name: "workersTest/follow", + kernel: "cpu", + components: { read: [position, target], write: [offset] }, + lookup: { targets: { read: [position] } }, + after: [split], +}, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + const [x] = row[position]; + row[offset] = other === null ? -1 : x - other[position][0]; +}, ); + +const main = System.create({ name: "workersTest/main", systems: [integrate, split, follow] }); + +/** Every row writes the same entity, so only merge order decides which value survives. */ +const stamp = System.create({ + name: "workersTest/stamp", + kernel: "cpu", + input: { type: "object", properties: { entity: Entity.schema } }, + components: { read: [position] }, +}, (row, { input, commands }) => { + const [x, y] = row[position]; + commands.add(input.entity, offset, x * 1000 + y); +}); + +/** Despawns entity 1, the resource entity, since index 0 is never an entity and registration reserves the next first. */ +const unlink = System.create({ + name: "workersTest/unlink", + kernel: "cpu", + components: { has: [target] }, +}, (_, { commands }) => { + // @ts-expect-error an untyped caller can pass any number + commands.despawn(1); +}); + +/** Archetypes in this order: 5 rows the main thread runs, 1,000 that workers run, then 10 more on the main thread. */ +const sources = System.create({ name: "workersTest/sources" }, ({ commands }) => { + for (let i = 0; i < 5; i++) commands.spawn([offset, tagD])({ [offset]: i }); + for (let i = 5; i < 1_005; i++) commands.spawn([offset, tagA])({ [offset]: i }); + for (let i = 1_005; i < 1_015; i++) commands.spawn([offset, tagB])({ [offset]: i }); +}); + +/** A spawn per row, from wherever its chunk runs; with `edit`, each spawn is also added to and removed from. */ +const emit = System.create({ + name: "workersTest/emit", + kernel: "cpu", + input: { type: "object", properties: { edit: { type: "boolean" } } }, + components: { read: [offset], not: [spawned] }, +}, (row, { input, commands }) => { + const entity = commands.spawn([position, spawned, tagC])({ [position]: [row[offset], -row[offset]] }); + if (input.edit) { + commands.add(entity, velocity, [row[offset], 1]); + commands.remove(entity, tagC); + } +}); + +const emitted = { seen: 0 }; +const countEmitted = System.create({ name: "workersTest/countEmitted", components: { added: [position], has: [spawned] } }, () => { emitted.seen++; }); + +const gathered: Entity[] = []; +const gatherPositioned = System.create({ name: "workersTest/gatherPositioned", observer: true, components: { has: [position] } }, row => { gathered.push(row.entity); }); +const gatherSpawned = System.create({ name: "workersTest/gatherSpawned", observer: true, components: { has: [spawned] } }, row => { gathered.push(row.entity); }); + +/** The entities an observer row system passes, in storage order. */ +const storageOrder = async (world: World, gather: System): Promise => { + gathered.length = 0; + await world.run(gather); + return [...gathered]; +}; + +/** Runs `use` with a world, then disposes it. */ +const using = async (world: World, use: (world: World) => Promise): Promise => { + try { + return await use(world); + } + finally { + await world.dispose(); + } +}; + +/** Every entity's values, in a canonical order, since entity ids depend on reservation interleaving. */ +const snapshot = async (world: World): Promise => { + const rows = await Promise.all((await select(world, { required: [position] })).map(entity => Promise.all([ + read(world, entity, position), + read(world, entity, velocity), + read(world, entity, offset), + read(world, entity, spawned), + ]))); + return rows.map(row => JSON.stringify(row)).sort(); +}; + +/** 2,000 following entities run through `main` for 12 frames, on the platform's workers or these, and the run tasks posted to workers. */ +export const simulate = async (count: number, wasm = true, simd = false, platform?: WorkerPlatform): Promise<{ readonly rows: readonly string[]; readonly tasks: number }> => { + const counting = countingWorkers(platform); + return using(createWorld({ [workers]: count }, { minimumChunk: 64, wasm, simd, pool: counting.platform }), async world => { + await world.run(seed, { count: 2000 }); + for (let frame = 0; frame < 12; frame++) await world.run(main); + return { rows: await snapshot(world), tasks: counting.posted("run") }; + }); +}; + +/** `integrate` over 512 archetypes, three frames. */ +export const scattered = (count: number): Promise => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { + await world.run(scatter); + for (let frame = 0; frame < 3; frame++) await world.run(integrate); + return snapshot(world); +}); + +/** What survives every row's command to one entity, and every position. */ +export const ordered = (count: number): Promise => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { + await world.run(seed, { count: 2000 }); + for (let frame = 0; frame < 4; frame++) await world.run(main); + const [first] = await storageOrder(world, gatherPositioned); + await world.run(stamp, { entity: first }); + return [await read(world, first, offset), await readEach(world, await storageOrder(world, gatherPositioned), position)]; +}); + +/** Kernel spawns from 1,015 rows over three archetypes: how many an `added` system saw, and the spawns' values. */ +export const spawns = (count: number, edit: boolean): Promise => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { + await world.run(sources); + await world.run(emit, { edit }); + emitted.seen = 0; + await world.run(countEmitted); + await world.run(countEmitted); + const entities = await storageOrder(world, gatherSpawned); + return [emitted.seen, await readEach(world, entities, position), await readEach(world, entities, velocity), await readEach(world, entities, tagC)]; +}); + +/** The resource after a worker kernel despawned the resource entity. */ +export const unlinked = (): Promise => using(createWorld({ [workers]: 2 }, { minimumChunk: 64 }), async world => { + await world.run(seed, { count: 500 }); + await world.run(unlink); + await world.run(integrate); + return readResource(world, seconds); +}); + +/** Workers ended by `dispose`, and what a run after it rejects with. */ +export const disposed = async (): Promise<{ readonly started: number; readonly terminated: number; readonly after: string }> => { + const counting = countingWorkers(); + const world = createWorld({ [workers]: 2 }, { pool: counting.platform }); + // Workers start with the first kernel. + await world.run(main); + await world.dispose(); + const after = await world.run(main).then(() => "resolved", (error: unknown) => String(error)); + return { started: counting.started(), terminated: counting.terminated(), after }; +}; diff --git a/packages/ecs/src/implementation/world/world-host.fixture.ts b/packages/ecs/src/implementation/world/world-host.fixture.ts new file mode 100644 index 000000000..f2658f37a --- /dev/null +++ b/packages/ecs/src/implementation/world/world-host.fixture.ts @@ -0,0 +1,10 @@ +import { scopeChannel } from "./port.js"; +import { lentWorkers } from "./worker-platform.js"; +import { simulate } from "./workers.fixture.js"; +import type { FromHost, ToHost } from "./ports.fixture.js"; + +/** A worker holding a world: runs `simulate(3)` with nested workers or ones its parent lent, and posts the result. */ +const channel = scopeChannel(); +channel.onMessage(async message => { + channel.post(await simulate(3, true, false, message.kind === "lent" ? lentWorkers(message.lent) : undefined)); +}); diff --git a/packages/ecs/src/index.ts b/packages/ecs/src/index.ts new file mode 100644 index 000000000..53fc4dd00 --- /dev/null +++ b/packages/ecs/src/index.ts @@ -0,0 +1,16 @@ +import { create, join } from './implementation/index.js'; +import type { Create } from './interface/world/create.js'; +import type { Join } from './interface/world/join.js'; +import type { World as Created } from './interface/world/world.js'; + +export * from './interface/index.js'; + +/** A world: what systems read and change. */ +export type World = Created; + +/** + * How a world is made, `World.create(resources?)`, on the thread that will run it, with resources starting at the given + * values in place of their defaults; and how another thread joins one that shared itself with it, + * `World.join(port, { threads })`. + */ +export const World: { readonly create: Create; readonly join: Join } = { create, join }; diff --git a/packages/ecs/src/interface/CLAUDE.md b/packages/ecs/src/interface/CLAUDE.md new file mode 100644 index 000000000..25263353d --- /dev/null +++ b/packages/ecs/src/interface/CLAUDE.md @@ -0,0 +1,33 @@ +# The interface + +This folder is the public contract, and only the minimal one: what a consumer must name or call to use the ECS +correctly. Everything that can be hidden is an implementation detail and stays in `src/implementation`. Each hidden +part is one the implementation can change freely later. + +## Export a type only for consumers to declare with + +Name and export a type only when a consumer would use it to declare something in their own code. Every exported name +is more for a reader to learn. A type that only describes a parameter, a result or part of one stays unexported, often +inline. A consumer who wants it can infer it, for example `Parameters[0]`. + +## Humans own the interface + +Humans own and review this folder; agents own the implementation. An agent doesn't add to or widen the contract on its +own. That includes: + +- exported types, values and functions; +- fields, options, parameters and return types; +- union members, failure kinds and report fields; +- built-in components and resources; +- the documented behaviour of an existing call. + +When work seems to need a change here, first find a way to keep it out: + +- Infer the type the implementation needs (`Parameters<…>`, `ReturnType<…>`, indexed access) instead of exporting a + name for it. +- Keep protocol state, progress and bookkeeping in the implementation's own storage, not in components or resources + consumers can see. +- Give a value a narrower public type than its runtime shape. + +If it truly can't be hidden, don't make the change. Propose it in your report, with the smallest form that would work +and why nothing smaller does, and leave it for human review. diff --git a/packages/ecs/src/interface/archetype/archetype.ts b/packages/ecs/src/interface/archetype/archetype.ts new file mode 100644 index 000000000..e01e40585 --- /dev/null +++ b/packages/ecs/src/interface/archetype/archetype.ts @@ -0,0 +1,3 @@ +export type Archetype = never; + +export * as Archetype from './index.js'; diff --git a/packages/ecs/src/interface/archetype/archetype.type-test.ts b/packages/ecs/src/interface/archetype/archetype.type-test.ts new file mode 100644 index 000000000..3f1d23bb1 --- /dev/null +++ b/packages/ecs/src/interface/archetype/archetype.type-test.ts @@ -0,0 +1,37 @@ +import { Resource } from '../resource/resource.js'; +import { Component } from '../component/component.js'; +import { Archetype, System } from "@adobe/ecs"; + +const position = Symbol("archetypeTypeTest/position"); +const velocity = Symbol("archetypeTypeTest/velocity"); +const scale = Symbol("archetypeTypeTest/scale"); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } + interface Resources { + [scale]: { type: number }; + } +} +Component.register(position, { type: { type: "number" } }); +Component.register(velocity, { type: { type: "number" } }); +Resource.register(scale, { type: { type: "number", default: 0 } }); + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +const expectType = (): T | undefined => undefined; + +// `create` returns its argument, typed as the tuple it was given. +export const particle = Archetype.create([position, velocity]); +expectType>(); +export const one: readonly Component.Key[] = particle; +// A plain readonly list of components is an archetype too. +export const plain = [position] as const; +export const moveOver = System.create({ name: "archetypeTypeTest/moveOver", components: { write: plain } }, row => { row[position] += 1; }); +// @ts-expect-error a resource is never in an archetype +Archetype.create([position, scale]); +// @ts-expect-error nor is a string +Archetype.create(["position"]); +// Archetypes go wherever component lists do. +export const move = System.create({ name: "archetypeTypeTest/move", components: { write: particle } }, row => { row[position] += row[velocity]; }); +export const read = System.create({ name: "archetypeTypeTest/read", observer: true, components: { read: particle } }, row => { void row[velocity]; }); diff --git a/packages/ecs/src/interface/archetype/create.test.ts b/packages/ecs/src/interface/archetype/create.test.ts new file mode 100644 index 000000000..590fd881a --- /dev/null +++ b/packages/ecs/src/interface/archetype/create.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../component/component.js"; +import { archetypeIndexOf } from "../registry/index.js"; +import { Archetype } from "./archetype.js"; + +const position = Symbol("archetypeCreateTest/position"); +const velocity = Symbol("archetypeCreateTest/velocity"); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } +} +Component.register(position, { type: { type: "number" } }); +Component.register(velocity, { type: { type: "number" } }); + +describe("Archetype.create", () => { + it("returns its argument, with a program-wide index of its own", () => { + const list = [position, velocity] as const; + const particle = Archetype.create(list); + const other = Archetype.create([velocity]); + expect(particle).toBe(list); + expect(particle).toEqual([position, velocity]); + expect(archetypeIndexOf(particle)).toBeGreaterThanOrEqual(0); + expect(archetypeIndexOf(other)).toBeGreaterThan(archetypeIndexOf(particle)); + expect(Object.keys(particle)).toEqual(["0", "1"]); + }); + it("keeps the index of a list given again", () => { + const particle = Archetype.create([position]); + const index = archetypeIndexOf(particle); + expect(Archetype.create(particle)).toBe(particle); + expect(archetypeIndexOf(particle)).toBe(index); + }); + it("gives a plain list no index", () => { + expect(archetypeIndexOf([position])).toBe(-1); + }); + it("throws for a component listed twice", () => { + expect(() => Archetype.create([position, velocity, position])).toThrow(`An archetype lists "archetypeCreateTest/position" twice.`); + }); +}); diff --git a/packages/ecs/src/interface/archetype/create.ts b/packages/ecs/src/interface/archetype/create.ts new file mode 100644 index 000000000..10075306a --- /dev/null +++ b/packages/ecs/src/interface/archetype/create.ts @@ -0,0 +1,12 @@ +import type { Component } from '../component/component.js'; +import { indexArchetype } from '../registry/archetypes.js'; + +/** + * Declares an archetype, the components an entity has, listed once each: returns `components`, typed as the tuple + * given, with a program-wide index the world finds its storage by. It goes wherever a list of components does, as any + * readonly list of components does. Throws for a component listed twice. + */ +export const create = (components: C): C => { + indexArchetype(components); + return components; +}; diff --git a/packages/ecs/src/interface/archetype/index.ts b/packages/ecs/src/interface/archetype/index.ts new file mode 100644 index 000000000..8fbd8a7ef --- /dev/null +++ b/packages/ecs/src/interface/archetype/index.ts @@ -0,0 +1 @@ +export * from './create.js'; diff --git a/packages/ecs/src/interface/archetype/values-of.ts b/packages/ecs/src/interface/archetype/values-of.ts new file mode 100644 index 000000000..89aaf8b76 --- /dev/null +++ b/packages/ecs/src/interface/archetype/values-of.ts @@ -0,0 +1,16 @@ +import type { Component } from '../component/component.js'; + +/** Components a spawn or add may leave out: a tag, whose value is `true`, and one whose type allows `null`, its start. */ +type Omissible = Component.Value extends true ? K : null extends Component.Value ? K : never; + +/** + * The values a spawn or add of the components `C` takes, keyed by component and exact against `C`: a tag needs no + * value, a component whose type allows `null` may be left out to start at `null`, and every other one is required. + * Leaving one out, or giving `undefined`, never leaves the component out. A list that isn't a tuple takes any of its + * components' values. + */ +export type ValuesOf = number extends C["length"] + // A list whose components aren't known until it runs, so any of them may be given. + ? { readonly [K in C[number]]?: Component.Value } + : { readonly [K in C[number] as K extends Omissible ? never : K]: Component.Value } + & { readonly [K in C[number] as K extends Omissible ? K : never]?: Component.Value }; diff --git a/packages/ecs/src/interface/component/component.ts b/packages/ecs/src/interface/component/component.ts new file mode 100644 index 000000000..39ce53ef8 --- /dev/null +++ b/packages/ecs/src/interface/component/component.ts @@ -0,0 +1,4 @@ + +export type Component = never; + +export * as Component from './index.js'; diff --git a/packages/ecs/src/interface/component/declaration.ts b/packages/ecs/src/interface/component/declaration.ts new file mode 100644 index 000000000..7844966a3 --- /dev/null +++ b/packages/ecs/src/interface/component/declaration.ts @@ -0,0 +1,67 @@ +import type { IsData } from "../schema/is-data.js"; +import type { Schema } from "../schema/schema.js"; +import type { Key as TraitKey } from "../trait/key.js"; +import type { Required } from "../trait/required.js"; +import type { Traits } from "../trait/types.js"; +import type { SymbolKeys } from "../trait/symbol-keys.js"; +import type { Key } from "./key.js"; +import type { Components } from "./types.js"; + +/** A value starts at `default`, which a type that allows `null` may leave out: it's `null` then. */ +export type Starting = null extends T ? { readonly default?: T } : { readonly default: T }; + +type Same = (() => X extends A ? 1 : 2) extends (() => X extends B ? 1 : 2) ? true : false; + +/** How deep `Flattened` goes: deeper values are compared as they are, so recursive types stay finite. */ +type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7]; + +/** `T` with every object, at any depth up to eight, as one object type, so `A & B` compares equal to the object it is. */ +type Flattened = + [D] extends [never] ? T : + T extends string | number | boolean | bigint | symbol | null | undefined | ((...parameters: never[]) => unknown) ? T : + T extends ReadonlyMap ? (T extends Map ? T : ReadonlyMap, Flattened>) : + T extends ReadonlySet ? (T extends Set ? T : ReadonlySet>) : + { [K in keyof T]: Flattened }; + +/** Whether `A` and `B` are the same type, flattened only when they aren't identical. */ +type Equivalent = Same extends true ? true : Same, Flattened>; + +/** + * Nothing when schema `S` describes exactly the declared type `T` and its `default`, if any, is a `T`; else a required + * property naming the problem, so registration fails to compile and says why. A check rather than `type`'s own type, + * so `type` is inferred from the schema alone. + */ +export type Describing = + ((S extends Schema ? Equivalent, T> : false) extends true ? {} : { readonly "the schema describes another type than the declared one": T }) + & (S extends { readonly default: infer V } ? ([V] extends [T] ? {} : { readonly "the schema's default isn't of the declared type": T }) : {}); + +/** A derived component starts at its schema's `default`, at its `const`, or at `null` when its type allows it. */ +type DerivedStart = + S extends { readonly const: unknown } | { readonly default: unknown } ? {} : + null extends T ? {} : + { readonly "a derived component's schema has a default, which it starts at": T }; + +/** + * The value type's part of a declaration: `type`, schema `S`, describing exactly the declared type `T` when it's data. + * A type that isn't data has no `type`, and a derived one starts at the declaration's `default`. + */ +type TypePart = + IsData extends true + ? [S] extends [never] ? { readonly type: Schema } : Describing & { readonly default?: never } & (Derived extends true ? DerivedStart : {}) + : { readonly type?: never } & (Derived extends true ? Starting : { readonly default?: never }); + +/** + * What `register(K, ...)` takes: exactly the traits `Components[K]` declares, `type` as schema `S` for the declared + * type. A trait whose declared value its definition doesn't allow on `K`, or one a value of its type must declare and + * doesn't, becomes `never`, so registration fails to compile, as every registration does while a component, resource + * or trait is keyed by a string (`SymbolKeys`). + */ +export type Declaration = + { readonly type?: S } + & TypePart + & { readonly [T in Exclude]: + T extends TraitKey ? (Components[K][T] extends Traits[T] ? Components[K][T] : never) : + never } + & { readonly [T in Exclude]?: never } + & Required + & SymbolKeys; diff --git a/packages/ecs/src/interface/component/declaration.type-test.ts b/packages/ecs/src/interface/component/declaration.type-test.ts new file mode 100644 index 000000000..4ffc738f4 --- /dev/null +++ b/packages/ecs/src/interface/component/declaration.type-test.ts @@ -0,0 +1,87 @@ +import { Entity } from '../entity/entity.js'; +import { Vec3 } from '../math/index.js'; +import { Component } from './component.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const position = Symbol("declarationTypeTest/position"); +const score = Symbol("declarationTypeTest/score"); +const parent = Symbol("declarationTypeTest/parent"); +const follows = Symbol("declarationTypeTest/follows"); +const tags = Symbol("declarationTypeTest/tags"); +const weights = Symbol("declarationTypeTest/weights"); +const element = Symbol("declarationTypeTest/element"); +const handle = Symbol("declarationTypeTest/handle"); +const cache = Symbol("declarationTypeTest/cache"); +const level = Symbol("declarationTypeTest/level"); + +// The declared traits are the source of truth; registration must give exactly them. +declare module '@adobe/ecs' { + export interface Components { + [position]: { type: Vec3 }; + [score]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [follows]: { type: Entity; reference: "weak" }; + [tags]: { type: ReadonlySet }; + [weights]: { type: ReadonlyMap }; + [element]: { type: HTMLElement; thread: "main" }; + [handle]: { type: { readonly release: () => void } }; + [cache]: { type: Map }; + [level]: { type: number; derived: true }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(score, { type: { type: "integer" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(follows, { type: Entity.schema, reference: "weak" }); +Component.register(tags, { type: { type: "set", items: { type: "string" } } }); +Component.register(weights, { type: { type: "map", keys: Entity.schema, values: { type: "number" } } }); +Component.register(level, { type: { type: "number", default: 0 }, derived: true }); + +// A schema describes exactly the declared type. +// @ts-expect-error a variable-length array isn't a Vec3 +Component.register(position, { type: { type: "array", items: { type: "number" } } }); +// @ts-expect-error a string isn't a number +Component.register(score, { type: { type: "string" } }); +// @ts-expect-error a number's schema isn't an entity's +Component.register(parent, { type: { type: "integer" }, reference: "owner" }); +// @ts-expect-error a set of numbers isn't a set of strings +Component.register(tags, { type: { type: "set", items: { type: "number" } } }); +// @ts-expect-error an array isn't a set +Component.register(tags, { type: { type: "array", items: { type: "string" } } }); +// @ts-expect-error a map's keys are entities +Component.register(weights, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } } }); + +// Every declared trait is registered, with its declared value, and nothing undeclared is. +// @ts-expect-error a declared trait is missing +Component.register(parent, { type: Entity.schema }); +// @ts-expect-error an undeclared trait is given +Component.register(score, { type: { type: "number" }, thread: "main" }); +// @ts-expect-error an undeclared trait is given +Component.register(score, { type: { type: "number" }, derived: true }); +// @ts-expect-error a trait's value differs from the declared one +Component.register(follows, { type: Entity.schema, reference: "owner" }); +// A declaration held in a constant gets no excess property check, so the types name each undeclared trait. +const placed = { type: { type: "number" }, thread: "main" } as const; +// @ts-expect-error an undeclared trait is given +Component.register(score, placed); + +// A type that's data is registered with its schema; one that isn't data has none, and is stored as it is. +// @ts-expect-error a number is data, so it has a schema +Component.register(score, {}); +// @ts-expect-error a set of strings is data too +Component.register(tags, {}); +Component.register(element, { thread: "main" }); +Component.register(handle, {}); +// A Set or Map changes in place, so it isn't data. +Component.register(cache, {}); +// @ts-expect-error a value that isn't data has no schema +Component.register(handle, { type: { type: "object", properties: {} } }); + +// Value types are the declared ones. +check, Vec3>>(); +check, ReadonlySet>>(); +check, ReadonlyMap>>(); +check, HTMLElement>>(); diff --git a/packages/ecs/src/interface/component/index.ts b/packages/ecs/src/interface/component/index.ts new file mode 100644 index 000000000..780e23943 --- /dev/null +++ b/packages/ecs/src/interface/component/index.ts @@ -0,0 +1,3 @@ +export * from './register.js'; +export type { Key } from './key.js'; +export type { Value, With } from './value.js'; diff --git a/packages/ecs/src/interface/component/key.ts b/packages/ecs/src/interface/component/key.ts new file mode 100644 index 000000000..02bfa15f0 --- /dev/null +++ b/packages/ecs/src/interface/component/key.ts @@ -0,0 +1,3 @@ +import type { Components } from './types.js'; + +export type Key = keyof Components; diff --git a/packages/ecs/src/interface/component/key.type-test.ts b/packages/ecs/src/interface/component/key.type-test.ts new file mode 100644 index 000000000..ac8a72377 --- /dev/null +++ b/packages/ecs/src/interface/component/key.type-test.ts @@ -0,0 +1,31 @@ +import { Component, Resource } from "@adobe/ecs"; +import type { Key as TraitKey } from "../trait/key.js"; + +const position = Symbol("keyTypeTest/position"); +const scale = Symbol("keyTypeTest/scale"); +const mark = Symbol("keyTypeTest/mark"); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + } + interface Resources { + [scale]: { type: number }; + } + interface Traits { + readonly [mark]: true; + } +} +Component.register(position, { type: { type: "number" } }); +Resource.register(scale, { type: { type: "number", default: 0 } }); + +// Components, resources and traits are named by keys; systems, rules and finalizers by names. +export const component: Component.Key = position; +export const resource: Resource.Key = scale; +export const trait: TraitKey = mark; +export const builtIn: TraitKey = "derived"; +// @ts-expect-error a resource isn't a component +export const resourceAsComponent: Component.Key = scale; +// @ts-expect-error a component isn't a resource +export const componentAsResource: Resource.Key = position; +// @ts-expect-error only the core's traits are named by strings +export const unknownTrait: TraitKey = "unknown"; diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts new file mode 100644 index 000000000..d37229da9 --- /dev/null +++ b/packages/ecs/src/interface/component/register.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { Entity } from "../entity/entity.js"; +import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; +import { Trait } from "../trait/trait.js"; +import { Component } from "./component.js"; + +const parent = Symbol("typesComponentTest/parent"); +const count = Symbol("typesComponentTest/count"); +const label = Symbol("typesComponentTest/label"); +const placed = Symbol("typesComponentTest/placed"); +const position = Symbol("typesComponentTest/position"); +const bitmap = Symbol("typesComponentTest/bitmap"); +const marked = Symbol("typesComponentTest/marked"); +const mark = Symbol("typesComponentTest/mark"); +const late = Symbol("typesComponentTest/late"); +const slot = Symbol("typesComponentTest/slot"); + +declare module "@adobe/ecs" { + export interface Threads { + typesComponentTest: true; + } + export interface Components { + [parent]: { type: Entity; reference: "owner" }; + [count]: { type: number }; + [label]: { type: string }; + [placed]: { type: readonly string[]; thread: "typesComponentTest" }; + [position]: { type: readonly [number, number] }; + [bitmap]: { type: ImageBitmap; transfer: true }; + [marked]: { type: true; [mark]: true }; + [slot]: { type: number; derived: true }; + } + export interface Traits { + readonly [mark]: true; + readonly [late]: true; + } +} + +Trait.register(mark); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(count, { type: { type: "number" } }); +Component.register(label, { type: { type: "string" } }); +Component.register(placed, { type: { type: "array", items: { type: "string" } }, thread: "typesComponentTest" }); +Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(bitmap, { transfer: true }); +Component.register(marked, { type: { const: true }, [mark]: true }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); + +const untyped: (...parameters: never[]) => unknown = Component.register; + +/** Registers as an untyped caller would, bypassing the types' checks. */ +const register = (key: unknown, declaration: object): void => { + Reflect.apply(untyped, undefined, [key, declaration]); +}; + +describe("Component.register", () => { + it("records the declaration, and its thread: none for a struct or a string, else its own or \"world\"", () => { + expect(declarationOf(parent)).toEqual({ type: Entity.schema, reference: "owner" }); + expect(kindOf(parent)).toBe("component"); + expect(threadOfDeclaration({ type: Entity.schema })).toBe(null); + expect(threadOfDeclaration({ type: { const: true } })).toBe(null); + expect(threadOfDeclaration({ type: { type: "object", properties: { at: { type: "number" } } } })).toBe(null); + expect(threadOfDeclaration({ type: { type: "array", items: { type: "number" } } })).toBe("world"); + expect(threadOfDeclaration({ type: { type: "object", properties: { at: { type: "number" } }, required: [] } })).toBe("world"); + expect([parent, label, placed, position].map(name => threadOfDeclaration(declarationOf(name) ?? {}))).toEqual([null, null, "typesComponentTest", null]); + expect(threadOfDeclaration({ type: { anyOf: [{ const: "edit" }, { const: "view" }] } })).toBe(null); + expect(threadOfDeclaration({ type: { anyOf: [{ type: "string" }, { type: "null" }] } })).toBe(null); + expect(threadOfDeclaration({ type: { anyOf: [{ type: "string" }, { type: "number" }] } })).toBe("world"); + }); + it("accepts the same declaration again, as a module evaluated again makes it, and rejects a different one or a key of another kind", () => { + expect(() => Component.register(count, { type: { type: "number" } })).not.toThrow(); + expect(() => register(count, { type: { type: "integer" } })).toThrow(`Cannot register component "typesComponentTest/count": already registered as a component, with another declaration.`); + expect(declarationOf(count)).toEqual({ type: { type: "number" } }); + const claimed = Symbol("typesComponentTest/claimed"); + register(claimed, { type: { const: true } }); + expect(() => Reflect.apply(Trait.register, undefined, [claimed])).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); + expect(kindOf(count)).toBe("component"); + }); + it("takes only plain symbols as keys, so built-in traits' string names are no component's", () => { + // @ts-expect-error a key is a symbol, and derived is a built-in trait's name + expect(() => Component.register("derived", { type: { const: true } })).toThrow(`Cannot register component "derived": a key is a plain Symbol(...), not a string.`); + expect(() => register(Symbol.for("typesComponentTest/shared"), { type: { const: true } })).toThrow("a Symbol.for key is shared across the program"); + expect(() => register(Symbol.asyncIterator, { type: { const: true } })).toThrow("Symbol.asyncIterator is a well-known symbol."); + expect(kindOf("derived")).toBe(undefined); + }); + it("rejects what types reject, for untyped callers, recording nothing", () => { + expect(() => register(Symbol("typesComponentTest/strayTrait"), { type: { type: "number" }, unknown: true })).toThrow("unknown isn't a registered trait"); + expect(() => register(Symbol("typesComponentTest/badReference"), { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); + expect(() => register(Symbol("typesComponentTest/badSlot"), { type: { type: "number" }, derived: true })).toThrow("a derived component's type has a default"); + expect(() => register(Symbol("typesComponentTest/reduced"), { type: { type: "number" }, reduce: "+" })).toThrow("only a resource is reduced"); + const badReference = Symbol("typesComponentTest/badReference"); + expect(() => register(badReference, { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); + expect(declarationOf(badReference)).toBe(undefined); + expect(kindOf(badReference)).toBe(undefined); + }); + it("rejects a schema whose GPU layout can't hold it", () => { + expect(() => register(Symbol("typesComponentTest/storageText"), { type: { type: "array", items: { type: "string" }, layout: "storage" } })).toThrow("layout needs numbers"); + }); + it("checks thread and transfer as far as values tell", () => { + expect(() => register(Symbol("typesComponentTest/placedStruct"), { type: { type: "number" }, thread: "main" })).toThrow("a struct lives in shared memory, so it has no thread"); + expect(() => register(Symbol("typesComponentTest/placedTag"), { type: { const: true }, thread: "main" })).toThrow("a struct lives in shared memory"); + expect(() => register(Symbol("typesComponentTest/placedText"), { type: { type: "string" }, thread: "main" })).toThrow("a string lives in shared memory, so it has no thread"); + expect(() => register(Symbol("typesComponentTest/placedMode"), { type: { anyOf: [{ const: "edit" }, { const: "view" }] }, thread: "main" })).toThrow("a string lives in shared memory"); + expect(() => register(Symbol("typesComponentTest/emptyThread"), { thread: "" })).toThrow("thread is a thread's name"); + expect(() => register(Symbol("typesComponentTest/dataTransfer"), { type: { type: "string" }, transfer: true })).toThrow("a value with a schema is data, which isn't Transferable"); + expect(() => register(Symbol("typesComponentTest/falseTransfer"), { transfer: false })).toThrow("transfer is true"); + }); + it("records no type for a value that isn't data", () => { + expect(declarationOf(bitmap)).toEqual({ transfer: true }); + expect(Reflect.has(declarationOf(bitmap) ?? {}, "type")).toBe(false); + expect(() => Component.register(bitmap, { transfer: true })).not.toThrow(); + }); + it("rejects a default beside a component's schema, which holds it", () => { + expect(() => register(Symbol("typesComponentTest/defaulted"), { type: { type: "number" }, default: 0 })).toThrow("its schema holds its default"); + }); + it("takes traits other packages register, in the namespace components share", () => { + const early = Symbol("typesComponentTest/early"); + expect(() => register(early, { type: { const: true }, [late]: true })).toThrow(`typesComponentTest/late isn't a registered trait`); + Trait.register(late); + expect(() => register(early, { type: { const: true }, [late]: true })).not.toThrow(); + expect(declarationOf(marked)).toEqual({ type: { const: true }, [mark]: true }); + expect(() => Trait.register(mark)).not.toThrow(); + expect(declarationOf(slot)).toEqual({ type: { type: "number", default: 0 }, derived: true }); + }); +}); diff --git a/packages/ecs/src/interface/component/register.ts b/packages/ecs/src/interface/component/register.ts new file mode 100644 index 000000000..cb40100dc --- /dev/null +++ b/packages/ecs/src/interface/component/register.ts @@ -0,0 +1,28 @@ +import { createRegistry, type AnyDeclaration } from "../registry/declarations.js"; +import type { Resource } from "../resource/resource.js"; +import type { Schema } from "../schema/schema.js"; +import type { Key as TraitKey } from "../trait/key.js"; +import type { Declaration } from "./declaration.js"; +import type { Key } from "./key.js"; + +const registry = createRegistry("component"); + +/** + * Registers the component `key`, a plain `Symbol(...)` whose traits `Components` declares, with exactly those traits: + * `type` as a schema that describes the declared type, left out only for a type that isn't data, which is stored as + * it is. + * ```ts + * declare module "@adobe/ecs" { interface Components { [position]: { type: Vec3 } } } + * Component.register(position, { type: Vec3.schema }); + * ``` + * The declaration is fixed from then on. The same declaration again is accepted, and so is a new symbol with the id + * of a component, as a module evaluated again makes it, which is then that key. A key also declared as a resource or + * a trait can't be registered: names are unique across the three. Throws for a key that isn't a plain symbol (a + * `Symbol.for` or well-known symbol isn't), one registered, or whose id is registered, as another kind or with another + * declaration, a trait that isn't registered, a trait's value that can't hold for the declaration, or a schema asking + * for a GPU layout it can't meet. + */ +export function register & symbol, const S extends Schema & { readonly default?: unknown } = never>(key: K, declaration: Declaration): void; +export function register(key: unknown, declaration: AnyDeclaration): void { + registry(key, declaration); +} diff --git a/packages/ecs/src/interface/component/types.ts b/packages/ecs/src/interface/component/types.ts new file mode 100644 index 000000000..3587be0b9 --- /dev/null +++ b/packages/ecs/src/interface/component/types.ts @@ -0,0 +1,6 @@ +/** + * Each component's traits, `type` included, by augmentation: `[position]: { type: Vec3 }`, + * `[slot]: { type: Slot; derived: true }`. Registration must give exactly these. + */ +export interface Components { +} diff --git a/packages/ecs/src/interface/component/value.ts b/packages/ecs/src/interface/component/value.ts new file mode 100644 index 000000000..0fb7d3a41 --- /dev/null +++ b/packages/ecs/src/interface/component/value.ts @@ -0,0 +1,34 @@ +import type { ThreadOf } from '../thread/of.js'; +import type { Key as TraitKey } from "../trait/key.js"; +import type { Key } from "./key.js"; +import type { Components } from "./types.js"; + +/** Every component's value type, computed once so `Value` is a lookup. */ +type ValueTypes = { readonly [K in Key]: Components[K] extends { readonly type: infer T } ? T : never }; + +/** The value type of component `K`. */ +export type Value = ValueTypes[K]; + +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +/** Component `K`'s value as reads return it: read-only, with branded primitives such as `Entity` as they are. */ +export type ReadonlyValue = Value extends Primitive ? Value : Readonly>; + +/** Components that declare trait `T`, with a value assignable to `V`. */ +export type With = { [K in Key]: Components[K] extends { readonly [P in T]: V } ? K : never }[Key]; + + +/** Components only rules add and remove. */ +export type Derived = With<"derived">; + +/** Components user code adds and removes: the only ones commands take. */ +export type Essential = Exclude; + +/** Components systems may write: every one but references, which change only through commands. */ +export type Writable = Exclude>; + +/** Every component's thread, computed once so `ThreadOf` is a lookup. */ +type Threads = { readonly [K in Key]: ThreadOf> }; + +/** The thread component `K` lives on, or `never` for a struct, which lives in shared memory. */ +export type ThreadOfComponent = Threads[K]; diff --git a/packages/ecs/src/interface/component/values.ts b/packages/ecs/src/interface/component/values.ts new file mode 100644 index 000000000..0247835f4 --- /dev/null +++ b/packages/ecs/src/interface/component/values.ts @@ -0,0 +1,5 @@ +import type { Key } from "./key.js"; +import type { Value } from './value.js'; + +/** Values for any subset of components, keyed by component. */ +export type Values = { readonly [K in Key]?: Value }; diff --git a/packages/ecs/src/interface/entity/entity.ts b/packages/ecs/src/interface/entity/entity.ts new file mode 100644 index 000000000..bd3bd88cc --- /dev/null +++ b/packages/ecs/src/interface/entity/entity.ts @@ -0,0 +1,9 @@ +declare const entityBrand: unique symbol; + +/** + * An entity id, live or not: a branded number; 0 is none. The brand makes entity-valued components and fields visible + * to types and schemas, and costs nothing at runtime. + */ +export type Entity = number & { readonly [entityBrand]: true }; + +export * as Entity from './index.js'; diff --git a/packages/ecs/src/interface/entity/index.ts b/packages/ecs/src/interface/entity/index.ts new file mode 100644 index 000000000..c2a34e6fa --- /dev/null +++ b/packages/ecs/src/interface/entity/index.ts @@ -0,0 +1,8 @@ +import type { Schema } from '../schema/schema.js'; +import type { Entity } from './entity.js'; + +/** The schema of every entity-typed value. */ +export const schema = { type: "integer", format: "entity" } as const satisfies Schema; + +/** No entity: index 0 is never one, so zeroed memory and an entity-valued default mean none, and a lookup of it returns null. */ +export const none = 0 as Entity; diff --git a/packages/ecs/src/interface/finalizer/body.ts b/packages/ecs/src/interface/finalizer/body.ts new file mode 100644 index 000000000..a226a5654 --- /dev/null +++ b/packages/ecs/src/interface/finalizer/body.ts @@ -0,0 +1,51 @@ +import type { Component } from '../component/component.js'; +import type { Entity } from '../entity/entity.js'; +import type { Resource } from '../resource/resource.js'; +import type { Commands } from '../system/commands.js'; +import type { Listed, Property } from '../system/property.js'; +import type { LookupFunction, ResourceValues, Values } from '../system/values.js'; +import type { ChangesDeclaration, Declaration } from './declaration.js'; +import type { Essential, ReadonlyValue } from '../component/value.js'; +import type { Elsewhere, ThreadsOf } from '../system/threads.js'; + +/** The entity that stopped matching, with its `read` values as they are before the sync point applies. */ +export type Row = + { readonly entity: Entity } & { readonly [K in Listed, "read"> & Component.Key]: Component.Value }; + +type Resources = Listed, "read"> | Listed, "write">; + +/** + * Resources and lookups, each present only when declared, and the commands it issues for the next sync point: none on + * another thread than the world's, which runs after the sync point. + */ +export type Context = + ([Resources] extends [never] ? {} : { + readonly resources: Values, "read">, Listed, "write">, Resource.Key, ResourceValues>; + }) + & ([keyof Property] extends [never] ? {} : { + readonly lookup: { readonly [K in keyof Property]: LookupFunction[K]> }; + }) + & (true extends Elsewhere> ? {} : { readonly commands: Commands }); + +/** The components a changes finalizer sees. */ +export type Watched = Property extends readonly Essential[] ? Listed & Essential : Essential; + +/** Entities whose components changed the same way: `from` and `to` are null for those spawned and despawned. */ +export type Group = { + readonly from: readonly C[] | null; + readonly to: readonly C[] | null; + readonly added: readonly C[]; + readonly removed: readonly C[]; + /** Given a value by an add while the entity has it before and after, removed and added again meanwhile or not. */ + readonly replaced: readonly C[]; + readonly entities: readonly Entity[]; +}; + +/** A sync point's structural changes to the watched components, and the values adds replace, before they apply. */ +export type ChangeSet = { + readonly groups: readonly Group[]; + /** The value before the sync point, or null if the entity didn't have it. */ + previous(entity: Entity, component: K): ReadonlyValue | null; + /** The value after it, or null if the entity won't have it. */ + next(entity: Entity, component: K): ReadonlyValue | null; +}; diff --git a/packages/ecs/src/interface/finalizer/create.test.ts b/packages/ecs/src/interface/finalizer/create.test.ts new file mode 100644 index 000000000..7c77fc0c1 --- /dev/null +++ b/packages/ecs/src/interface/finalizer/create.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../component/component.js"; +import { identify } from "../registry/ids.test-helper.js"; +import { labelOfItem, programFinalizers, programRules } from "../registry/index.js"; +import { Resource } from "../resource/resource.js"; +import { Rule } from "../rule/rule.js"; +import { clock } from "../resources/index.js"; +import { Finalizer } from "./finalizer.js"; + +const node = Symbol("typesFinalizerTest/node"); +const label = Symbol("typesFinalizerTest/label"); +const slot = Symbol("typesFinalizerTest/slot"); +const total = Symbol("typesFinalizerTest/total"); +const time = Symbol("typesFinalizerTest/time"); +const mark = Symbol("typesFinalizerTest/mark"); +const other = Symbol("typesFinalizerTest/other"); +const page = Symbol("typesFinalizerTest/page"); +const tags = Symbol("typesFinalizerTest/tags"); + +declare module "@adobe/ecs" { + export interface Components { + [node]: { type: HTMLElement; thread: "main" }; + [label]: { type: string }; + [slot]: { type: number; derived: true }; + [mark]: { type: true; derived: true }; + [other]: { type: true; derived: true }; + [tags]: { type: readonly string[] }; + } + export interface Resources { + [total]: { type: number; reduce: "+" }; + [time]: { type: number }; + [page]: { type: Document | null; thread: "main" }; + } +} + +Component.register(node, { thread: "main" }); +Component.register(label, { type: { type: "string" } }); +Component.register(tags, { type: { type: "array", items: { type: "string" } } }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(time, { type: { type: "number", default: 0 } }); +Resource.register(page, { thread: "main" }); +Component.register(mark, { type: { const: true }, derived: true }); +Component.register(other, { type: { const: true }, derived: true }); + +/** Declares as an untyped caller would, bypassing the types' checks. */ +const untyped = (constructor: (...parameters: never[]) => unknown, declaration: object): unknown => Reflect.apply(constructor, undefined, [declaration, () => {}]); + +describe("Finalizer.create and Finalizer.changes", () => { + it("record finalizers in creation order, keep the place of one declared again under its id with its new body, and reject its id with another declaration", () => { + const release = Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [node] } }, "f.ts#release"), () => {}); + const save = Finalizer.changes({ name: "typesFinalizerTest/save", watch: [label] }, () => {}); + expect(programFinalizers().map(finalizer => finalizer.name)).toEqual([release.name, save.name]); + const body = (): void => {}; + expect(Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [node] } }, "f.ts#release"), body)).toBe(release); + expect(programFinalizers()[0]?.kind === "finalizer" ? programFinalizers()[0]?.body : null).toBe(body); + expect(() => Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [label] } }, "f.ts#release"), () => {})).toThrow("reload the page"); + expect(() => Finalizer.changes(identify({ name: "typesFinalizerTest/release" }, "f.ts#release"), () => {})).toThrow("its id is a finalizer's"); + expect(programFinalizers()).toHaveLength(2); + }); + it("keep finalizers of one name apart, labeled apart", () => { + const first = Finalizer.changes({ name: "typesFinalizerTest/twin", watch: [label] }, () => {}); + const second = Finalizer.changes({ name: "typesFinalizerTest/twin", watch: [label] }, () => {}); + expect(second).not.toBe(first); + expect([first, second].map(labelOfItem)).toEqual(["typesFinalizerTest/twin", "typesFinalizerTest/twin#2"]); + const third = Finalizer.changes({ name: "typesFinalizerTest/ordered", watch: [label], after: [second], before: [first] }, () => {}); + expect(programFinalizers().slice(-3)).toEqual([second, third, first]); + }); + it("order finalizers by before and after, and reject a cycle or a resource conflict with no order", () => { + const late = Finalizer.create({ name: "typesFinalizerTest/late", components: { read: [label] }, resources: { read: [time] } }, () => {}); + const early = Finalizer.create({ name: "typesFinalizerTest/early", components: { read: [label] }, resources: { write: [time] }, before: [late] }, () => {}); + expect(programFinalizers().map(finalizer => finalizer.name).filter(name => name === early.name || name === late.name)).toEqual([early.name, late.name]); + expect(() => Finalizer.create({ name: "typesFinalizerTest/conflict", components: { read: [label] }, resources: { read: [time] } }, () => {})).toThrow('both access "typesFinalizerTest/time", one writing it'); + expect(() => Finalizer.create({ name: "typesFinalizerTest/cycle", components: { read: [label] }, before: [early], after: [late] }, () => {})).toThrow("ordered in a cycle"); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/clock", components: {}, resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); + expect(programFinalizers()).toHaveLength(7); + }); + it("reject what types reject, for untyped callers", () => { + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/both", components: {}, resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/reduced", components: {}, resources: { write: [total] } })).toThrow("which is reduced"); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/span", components: { read: [node, tags] } })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); + expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/derived", watch: [slot] })).toThrow("which is derived"); + expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/resource", watch: [time] })).toThrow("which isn't a registered component"); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/hasResource", components: { has: [time] } })).toThrow(`Finalizer "typesFinalizerTest/hasResource" uses component "typesFinalizerTest/time", which isn't a registered component`); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/lookupNot", components: { read: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } })).toThrow("which isn't a registered component"); + expect(programFinalizers()).toHaveLength(7); + }); + it("reject resource writes and lookups in a finalizer on another thread than the world's, and a changes finalizer watching or reading another thread's values", () => { + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/detachWriting", components: { read: [node] }, resources: { write: [time] } })) + .toThrow(`Finalizer "typesFinalizerTest/detachWriting" runs on thread "main" and writes resources; one on another thread than the world's runs after the sync point, so it writes none.`); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/pageWriting", components: { read: [tags] }, resources: { write: [page] } })) + .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/detachLooking", components: { read: [node] }, lookup: { labels: { read: [mark] } } })) + .toThrow(`Finalizer "typesFinalizerTest/detachLooking" runs on thread "main" and looks up other entities; one on another thread than the world's runs after the sync point, so it looks up none.`); + expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/saveNodes", watch: [node] })) + .toThrow(`Finalizer "typesFinalizerTest/saveNodes" watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); + expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/savePage", watch: [tags], resources: { read: [page] } })) + .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); + expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/savePageOnly", resources: { read: [page] } })) + .toThrow(`Finalizer "typesFinalizerTest/savePageOnly" watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); + // Reading another thread's values, without writing or looking up, it's created. + const detach = Finalizer.create({ name: "typesFinalizerTest/detach", components: { read: [node] }, resources: { read: [page] } }, () => {}); + expect(programFinalizers()).toHaveLength(8); + // Finalizers on different threads run at different times, so neither is ordered before the other. + const late = programFinalizers().find(finalizer => finalizer.name === "typesFinalizerTest/late"); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/afterDetach", components: { read: [label] }, after: [detach] })) + .toThrow(`Finalizer "typesFinalizerTest/afterDetach" runs on thread "world" and is ordered with "typesFinalizerTest/detach", which runs on thread "main"; finalizers on different threads run at different times, so neither is ordered before the other.`); + expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/beforeLate", components: { read: [node] }, before: [late] })) + .toThrow(`Finalizer "typesFinalizerTest/beforeLate" runs on thread "main" and is ordered with "typesFinalizerTest/late", which runs on thread "world"`); + expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/saveAfterDetach", watch: [label], after: [detach] })) + .toThrow(`Finalizer "typesFinalizerTest/saveAfterDetach" runs on thread "world" and is ordered with "typesFinalizerTest/detach"`); + Finalizer.create({ name: "typesFinalizerTest/detachAgain", components: { read: [node] }, after: [detach] }, () => {}); + expect(programFinalizers()).toHaveLength(9); + }); +}); + +describe("Rule.create", () => { + it("records a rule and returns it, the one already recorded when declared again under its id, and rejects its id with another declaration", () => { + const slots = Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [label] }, derive: [slot] }, "r.ts#slots")); + expect(programRules()).toEqual([slots]); + expect(Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [label] }, derive: [slot] }, "r.ts#slots"))).toBe(slots); + expect(() => Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [node] }, derive: [slot] }, "r.ts#slots"))).toThrow("reload the page"); + const twin = Rule.create({ name: "typesFinalizerTest/slots", components: { has: [node] }, derive: [slot] }); + expect(programRules()).toEqual([slots, twin]); + expect([slots, twin].map(labelOfItem)).toEqual(["typesFinalizerTest/slots (r.ts#slots)", "typesFinalizerTest/slots#2"]); + }); + it("rejects rules that derive in a cycle through a negation", () => { + Rule.create({ name: "typesFinalizerTest/marks", components: { not: [mark] }, derive: [other] }); + expect(() => Rule.create({ name: "typesFinalizerTest/others", components: { has: [other] }, derive: [mark] })).toThrow("cycle through a negation"); + expect(programRules()).toHaveLength(3); + }); + it("rejects a rule deriving a component that isn't derived, or matching on a resource", () => { + const create = (declaration: object): unknown => Reflect.apply(Rule.create, undefined, [declaration]); + expect(() => create({ name: "typesFinalizerTest/essential", components: {}, derive: [label] })).toThrow("which isn't declared derived"); + expect(() => create({ name: "typesFinalizerTest/onResource", components: { has: [time] }, derive: [slot] })).toThrow("which isn't a registered component"); + expect(programRules()).toHaveLength(3); + }); +}); + +describe("rules and finalizers without a name", () => { + it("are named by their binding and id, or else by their kind numbered", () => { + const rule = Rule.create(identify({ components: { has: [node] }, derive: [slot] }, "rules.ts#slotted")); + const release = Finalizer.create(identify({ components: { read: [node] } }, "finalizers.ts#release"), () => {}); + expect([rule, release].map(labelOfItem)).toEqual(["slotted (rules.ts#slotted)", "release (finalizers.ts#release)"]); + const labels = [Rule.create({ components: { has: [label] }, derive: [slot] }), Finalizer.changes({ watch: [label] }, () => {})].map(labelOfItem); + expect(labels.map(found => found.replace(/#\d+$/, "#n"))).toEqual(["rule #n", "finalizer #n"]); + }); +}); diff --git a/packages/ecs/src/interface/finalizer/create.ts b/packages/ecs/src/interface/finalizer/create.ts new file mode 100644 index 000000000..0442de2ce --- /dev/null +++ b/packages/ecs/src/interface/finalizer/create.ts @@ -0,0 +1,128 @@ +import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { takeIdOfDeclaration } from '../registry/ids.js'; +import { firstItemOf, labelOfItem, nameOf } from '../registry/items.js'; +import { addFinalizer, programFinalizers } from '../registry/program.js'; +import { requireRegistered, threadOf } from '../registry/threads.js'; +import { checkWorldWritten } from '../registry/written.js'; +import { checkLookup } from '../registry/indexes.js'; +import type { Resource } from '../resource/resource.js'; +import type { Listed, Property } from '../system/property.js'; +import type { ThreadOfComponent } from '../component/value.js'; +import type { Component } from '../component/component.js'; +import type { Elsewhere, OneThread, ThreadedBy, ThreadsOf } from '../system/threads.js'; +import type { ChangeSet, Context, Row, Watched } from './body.js'; +import type { Changes, Leaf } from './created.js'; +import type { ChangesDeclaration, Declaration } from './declaration.js'; +import type { Finalizer } from './finalizer.js'; +import type { Finalizer as Registered } from './created.js'; + +/** A resource in `write` is readable too, so it isn't also in `read`. */ +type Validate = + { readonly resources?: { readonly read?: readonly Exclude, "write">>[] } }; + +/** + * A finalizer on another thread than the world's runs there after the sync point, which doesn't wait for it: it writes + * no resources and looks up nothing. + */ +type Remote = true extends Elsewhere> + ? ([Listed, "write">] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's writes no resources": true }) + & ([keyof Property] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's looks up nothing": true }) + : {}; + +/** A changes finalizer runs on the world's thread, in the sync point: it watches and reads no other thread's objects. */ +type OnWorld = true extends Elsewhere | ThreadOfComponent & Component.Key>> + ? { readonly "error: a changes finalizer watches and reads only the world's thread's objects": true } + : {}; + +type Key = symbol; + +/** Throws if a resource is in both `read` and `write`, or a reduced one is written. */ +const checkResources = (name: string, resources: { readonly read?: readonly Key[]; readonly write?: readonly Key[] } | undefined): void => { + const writes = new Set(resources?.write); + const both = (resources?.read ?? []).find(key => writes.has(key)); + if (both !== undefined) throw new Error(`Finalizer "${name}" lists "${labelOf(both)}" in both resources read and write; write already makes it readable.`); + checkWorldWritten(`Finalizer "${name}"`, resources?.write); + for (const key of writes) { + if (declarationOf(key)?.reduce !== undefined) throw new Error(`Finalizer "${name}" writes "${labelOf(key)}", which is reduced; only its contributors' fold sets it.`); + } +}; + +/** The thread a registered finalizer runs on: a changes finalizer, and one using no objects, on the world's. */ +const runsOn = (finalizer: Registered): string => finalizer.kind === "finalizer" ? threadOf(finalizer.declaration, "Finalizer", finalizer.name) ?? "world" : "world"; + +/** Throws for a finalizer ordered with one on another thread: they run at different times. */ +const checkOrder = (name: string, thread: string, declaration: { readonly before?: readonly Finalizer[]; readonly after?: readonly Finalizer[] }): void => { + for (const other of [...declaration.before ?? [], ...declaration.after ?? []]) { + const first = firstItemOf(other); + const found = programFinalizers().find(finalizer => firstItemOf(finalizer) === first); + if (found === undefined) continue; + const theirs = runsOn(found); + if (theirs !== thread) { + throw new Error(`Finalizer "${name}" runs on thread "${thread}" and is ordered with "${labelOfItem(found)}", which runs on thread "${theirs}"; finalizers on different threads run at different times, so neither is ordered before the other.`); + } + } +}; + +/** + * Declares a finalizer for each entity that stops matching its components, which applies in every world, and returns + * it. It runs on the thread its object components and resources live on: on another than the world's, after the sync + * point, issuing no commands. Throws if it names a component or resource that isn't registered as one, lists a + * resource in both `read` and `write`, writes a reduced one, uses object values of different threads, writes resources + * or looks up entities on another thread than the world's, or is ordered with a finalizer on another thread, in a cycle + * or with a resource conflict. + */ +export const create = ( + declaration: D & Validate & OneThread, ThreadedBy> & Remote, + body: (row: Row, context: Context) => void, +): Finalizer => { + const id = takeIdOfDeclaration(declaration); + const name = nameOf(declaration.name, id, "finalizer"); + const finalizer: Leaf = { kind: "finalizer", name, declaration, body }; + checkResources(name, declaration.resources); + const thread = threadOf(declaration, "Finalizer", name); + if (thread !== null && thread !== "world") { + const elsewhere = `one on another thread than the world's runs after the sync point, so it`; + if ((declaration.resources?.write?.length ?? 0) > 0) throw new Error(`Finalizer "${name}" runs on thread "${thread}" and writes resources; ${elsewhere} writes none.`); + if (Object.keys(declaration.lookup ?? {}).length > 0) throw new Error(`Finalizer "${name}" runs on thread "${thread}" and looks up other entities; ${elsewhere} looks up none.`); + } + checkOrder(name, thread ?? "world", declaration); + requireRegistered("Finalizer", name, "component", declaration.components.has); + requireRegistered("Finalizer", name, "component", declaration.components.not); + for (const lookupName in declaration.lookup) { + checkLookup("Finalizer", name, lookupName, declaration.lookup[lookupName]); + const lookup = declaration.lookup[lookupName]; + requireRegistered("Finalizer", name, "component", "has" in lookup ? lookup.has : undefined); + requireRegistered("Finalizer", name, "component", "not" in lookup ? lookup.not : undefined); + } + return addFinalizer(finalizer, id); +}; + +/** + * Declares a finalizer of each sync point's structural changes, which applies in every world, and returns it. It runs + * on the world's thread. Throws as `create` does, for a watched component that isn't registered or is derived, and for + * one watching or reading another thread's values. + */ +export const changes = ( + declaration: D & Validate & OnWorld, + body: (changes: ChangeSet>, context: Context) => void, +): Finalizer => { + const id = takeIdOfDeclaration(declaration); + const name = nameOf(declaration.name, id, "finalizer"); + const finalizer: Changes = { kind: "changes", name, declaration, body }; + checkResources(name, declaration.resources); + requireRegistered("Finalizer", name, "resource", declaration.resources?.read); + requireRegistered("Finalizer", name, "resource", declaration.resources?.write); + for (const lookupName in declaration.lookup) { + const lookup = declaration.lookup[lookupName]; + checkLookup("Finalizer", name, lookupName, lookup); + for (const keys of [lookup.read, "has" in lookup ? lookup.has : undefined, "not" in lookup ? lookup.not : undefined]) requireRegistered("Finalizer", name, "component", keys); + } + for (const key of declaration.watch ?? []) { + if (kindOf(key) !== "component") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component.`); + if (declarationOf(key)?.derived === true) throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which is derived; it sees only components user code adds and removes.`); + } + const thread = threadOf({ components: { read: declaration.watch }, resources: declaration.resources, lookup: declaration.lookup }, "Finalizer", name); + if (thread !== null && thread !== "world") throw new Error(`Finalizer "${name}" watches or reads values on thread "${thread}"; a changes finalizer runs on the world's thread, in the sync point.`); + checkOrder(name, "world", declaration); + return addFinalizer(finalizer, id); +}; diff --git a/packages/ecs/src/interface/finalizer/created.ts b/packages/ecs/src/interface/finalizer/created.ts new file mode 100644 index 000000000..3eb56d4e0 --- /dev/null +++ b/packages/ecs/src/interface/finalizer/created.ts @@ -0,0 +1,21 @@ +import type { ChangesDeclaration, Declaration } from './declaration.js'; + +// Bodies' parameters are checked where they're created, so a finalizer holds any body. + +/** Runs, inside a sync point, for each entity that stops matching its components. */ +export type Leaf = { + readonly kind: "finalizer"; + readonly name: string; + readonly declaration: D; + body: (...parameters: never[]) => void; +}; + +/** Runs, inside a sync point, with all its structural changes to the components it watches. */ +export type Changes = { + readonly kind: "changes"; + readonly name: string; + readonly declaration: D; + body: (...parameters: never[]) => void; +}; + +export type Finalizer = Leaf | Changes; diff --git a/packages/ecs/src/interface/finalizer/declaration.ts b/packages/ecs/src/interface/finalizer/declaration.ts new file mode 100644 index 000000000..978ff0146 --- /dev/null +++ b/packages/ecs/src/interface/finalizer/declaration.ts @@ -0,0 +1,29 @@ +import type { Component } from '../component/component.js'; +import type { Resource } from '../resource/resource.js'; +import type { Lookup } from '../system/access.js'; +import type { Finalizer } from './finalizer.js'; +import type { Name } from './name.js'; +import type { Essential } from '../component/value.js'; +import type { Reduced } from '../resource/reduction.js'; +import type { WorldWritten } from '../resource/written.js'; + +type Shared = { + readonly name?: Name; + /** Read-only access to other entities, as they are before the sync point applies. */ + readonly lookup?: { readonly [name: string]: Lookup }; + /** Writes take the sync point's tick, so each system sees them once. A resource in `write` is also readable. */ + readonly resources?: { readonly read?: readonly Resource.Key[]; readonly write?: readonly Exclude[] }; + readonly before?: readonly Finalizer[]; + readonly after?: readonly Finalizer[]; +}; + +/** A finalizer that runs for each entity that stops matching its components. */ +export type Declaration = Shared & { + readonly components: { readonly read?: readonly Component.Key[]; readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; +}; + +/** + * A finalizer that sees each sync point's structural changes to the components it watches: by default, every one that + * isn't derived or an object a joined thread fills. + */ +export type ChangesDeclaration = Shared & { readonly watch?: readonly Essential[] }; diff --git a/packages/ecs/src/interface/finalizer/finalizer.ts b/packages/ecs/src/interface/finalizer/finalizer.ts new file mode 100644 index 000000000..af0218d8f --- /dev/null +++ b/packages/ecs/src/interface/finalizer/finalizer.ts @@ -0,0 +1,7 @@ +/** + * A finalizer, the value `Finalizer.create` or `Finalizer.changes` returned: identified by that value, labeled by its + * name, and listed in other finalizers' `before` and `after`. + */ +export type Finalizer = { readonly name: string }; + +export * as Finalizer from './index.js'; diff --git a/packages/ecs/src/interface/finalizer/index.ts b/packages/ecs/src/interface/finalizer/index.ts new file mode 100644 index 000000000..8fbd8a7ef --- /dev/null +++ b/packages/ecs/src/interface/finalizer/index.ts @@ -0,0 +1 @@ +export * from './create.js'; diff --git a/packages/ecs/src/interface/finalizer/name.ts b/packages/ecs/src/interface/finalizer/name.ts new file mode 100644 index 000000000..50cbfa06d --- /dev/null +++ b/packages/ecs/src/interface/finalizer/name.ts @@ -0,0 +1,6 @@ +/** + * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so + * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without + * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. + */ +export type Name = string; diff --git a/packages/ecs/src/interface/gpu/accessors.ts b/packages/ecs/src/interface/gpu/accessors.ts new file mode 100644 index 000000000..7c3d676ab --- /dev/null +++ b/packages/ecs/src/interface/gpu/accessors.ts @@ -0,0 +1,72 @@ +import { layoutOf, type Leaf } from '../registry/layout.js'; +import { declarationOf } from '../registry/index.js'; +import type { Shader } from '../system/render.js'; +import { labelOf } from '../registry/names.js'; + +/** A WGSL function reading one component of a row from the GPU copy of a render system's table. */ +export type Accessor = { readonly name: string; readonly component: symbol; readonly code: string }; + +const typeOf = (leaf: Leaf): string | null => { + if (leaf.scalar === "boolean") return leaf.view === "u32" ? "bool" : null; + return leaf.view === "f32" || leaf.view === "u32" || leaf.view === "i32" ? leaf.view : null; +}; + +const readOf = (leaf: Leaf, word: string): string => { + const element = `ecs_heap[${word}]`; + switch (leaf.scalar) { + case "boolean": return `(${element} != 0u)`; + case "f32": return `bitcast(${element})`; + case "i32": return `bitcast(${element})`; + default: return element; + } +}; + +/** WGSL keywords and reserved words an accessor can't be named. */ +const reserved = new Set(("alias break case const const_assert continue continuing default diagnostic discard else enable false fn for if let loop override requires return struct switch true var while " + + "NULL Self abstract active alignas alignof as asm asm_fragment async attribute auto await become cast catch class co_await co_return co_yield coherent column_major common compile compile_fragment concept const_cast consteval constexpr constinit crate debugger decltype delete demote demote_to_helper do dynamic_cast enum explicit export extends extern external fallthrough filter final finally friend from fxgroup get goto groupshared highp impl implements import inline instanceof interface layout lowp macro macro_rules match mediump meta mod module move mut mutable namespace new nil noexcept noinline nointerpolation non_coherent noncoherent noperspective null nullptr of operator package packoffset partition pass patch pixelfragment precise precision premerge priv protected pub public readonly ref regardless register reinterpret_cast require resource restrict self set shared sizeof smooth snorm static static_assert static_cast std subroutine super target template this thread_local throw trait try type typedef typeid typename typeof union unless unorm unsafe unsized use using varying virtual volatile wgsl where with writeonly yield").split(" ")); + +/** + * Accessors for a render system's shader, in `accessors` order, or the reason one can't be read on the GPU: scalars + * and tuples of two to four scalars of one type, laid out in 4-byte words. + */ +export const accessorsOf = (shader: Shader): readonly Accessor[] | string => { + const accessors: Accessor[] = []; + const entries = Object.entries(shader.accessors); + // A table record is a row count, then a word address per accessor. + if (entries.length > 63) return "it names more than 63 accessors"; + for (const [name, key] of entries) { + const component = labelOf(key); + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name) || name.startsWith("ecs_") || reserved.has(name)) return `"${name}" isn't usable as a WGSL function name`; + const declaration = declarationOf(key); + if (declaration === undefined) return `"${component}" has no registered schema`; + const layout = layoutOf(declaration.type); + if (layout.kind !== "linear") return `"${component}" isn't stored in linear memory`; + const { leaves, stride } = layout; + const types = leaves.map(typeOf); + if (stride % 4 !== 0 || leaves.some(leaf => leaf.offset % 4 !== 0) || types.some(type => type === null)) { + return `"${component}" isn't laid out in 4-byte words of f32, i32, u32 or booleans; give it a "storage" or "vertex" layout or 4-byte scalars`; + } + const words = stride / 4; + const word = (leaf: Leaf): string => `ecs_table.ecs_${name} + row * ${words}u + ${leaf.offset / 4}u`; + const [first] = leaves; + if (leaves.length === 1 && first.path.length === 0) { + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> ${types[0]} { return ${readOf(first, word(first))}; }` }); + continue; + } + const tuple = leaves.length >= 2 && leaves.length <= 4 && leaves.every((leaf, i) => leaf.path.length === 1 && leaf.path[0] === i && types[i] === types[0]); + if (!tuple) return `"${component}" isn't a scalar or a tuple of two to four scalars of one type`; + const items = leaves.map(leaf => readOf(leaf, word(leaf))).join(", "); + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> vec${leaves.length}<${types[0]}> { return vec${leaves.length}<${types[0]}>(${items}); }` }); + } + return accessors; +}; + +/** The WGSL declarations of a render system's bind group and accessors. */ +export const declarationsOf = (group: number, accessors: readonly Accessor[]): string => [ + `@group(${group}) @binding(0) var ecs_heap: array;`, + `struct EcsTable { ecs_rows: u32, ${accessors.map(accessor => `ecs_${accessor.name}: u32`).join(", ")} }`, + `@group(${group}) @binding(1) var ecs_table: EcsTable;`, + `fn ecs_count() -> u32 { return ecs_table.ecs_rows; }`, + ...accessors.map(accessor => accessor.code), + "", +].join("\n"); diff --git a/packages/ecs/src/interface/gpu/gpu.ts b/packages/ecs/src/interface/gpu/gpu.ts new file mode 100644 index 000000000..7441c863a --- /dev/null +++ b/packages/ecs/src/interface/gpu/gpu.ts @@ -0,0 +1,4 @@ +/** `Gpu.wgsl` and `Gpu.layout` give render systems' shaders and pipelines what they need from schemas alone. */ +export type Gpu = never; + +export * as Gpu from './index.js'; diff --git a/packages/ecs/src/interface/gpu/gpu.type-test.ts b/packages/ecs/src/interface/gpu/gpu.type-test.ts new file mode 100644 index 000000000..2648982a4 --- /dev/null +++ b/packages/ecs/src/interface/gpu/gpu.type-test.ts @@ -0,0 +1,27 @@ +import { Component } from '../component/component.js'; +import type { Gpu } from '../index.js'; +import { System } from '../system/system.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const tint = Symbol("gpuTypeTest/tint"); +declare module '@adobe/ecs' { + export interface Components { + [tint]: { type: number }; + } +} +Component.register(tint, { type: { type: "number" } }); + +declare const wgsl: typeof Gpu.wgsl; +declare const layout: typeof Gpu.layout; +// @ts-expect-error the type of Gpu.wgsl is typeof Gpu.wgsl, not a name of its own +export type Wgsl = Gpu.Wgsl; +// @ts-expect-error the type of Gpu.layout is typeof Gpu.layout, not a name of its own +export type Layout = Gpu.Layout; +check) => string>>(); +declare const device: GPUDevice; +const draw = System.render({ name: "gpuTypeTest/draw", shader: { accessors: { tint } } }, () => {}); +const code: string = wgsl(draw); +check GPUBindGroupLayout>>(); +export const used = [code, layout(device)]; diff --git a/packages/ecs/src/interface/gpu/index.ts b/packages/ecs/src/interface/gpu/index.ts new file mode 100644 index 000000000..43ea53b4a --- /dev/null +++ b/packages/ecs/src/interface/gpu/index.ts @@ -0,0 +1,2 @@ +export { layout } from './layout.js'; +export { wgsl } from './wgsl.js'; diff --git a/packages/ecs/src/interface/gpu/layout.ts b/packages/ecs/src/interface/gpu/layout.ts new file mode 100644 index 000000000..fe9364314 --- /dev/null +++ b/packages/ecs/src/interface/gpu/layout.ts @@ -0,0 +1,22 @@ +import { recordBytes } from './record.js'; + +const layouts = new WeakMap(); + +/** + * The bind group layout of `wgsl` declarations, which render pipelines take at the group they name. It's the same + * object for a device every time, so pipelines can be built before a world exists. + */ +export const layout = (device: GPUDevice): GPUBindGroupLayout => { + let existing = layouts.get(device); + if (existing === undefined) { + const visibility = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE; + existing = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility, buffer: { type: "read-only-storage" } }, + { binding: 1, visibility, buffer: { type: "uniform", hasDynamicOffset: true, minBindingSize: recordBytes } }, + ], + }); + layouts.set(device, existing); + } + return existing; +}; diff --git a/packages/ecs/src/interface/gpu/record.ts b/packages/ecs/src/interface/gpu/record.ts new file mode 100644 index 000000000..3fb6d90d3 --- /dev/null +++ b/packages/ecs/src/interface/gpu/record.ts @@ -0,0 +1,5 @@ +/** + * Bytes per parameter or table record, the dynamic offset alignment WebGPU guarantees. A record is `u32` words: a + * row count, then a word address in the heap per column, then, for kernels, scalars as f32 bits. + */ +export const recordBytes = 256; diff --git a/packages/ecs/src/interface/gpu/wgsl.ts b/packages/ecs/src/interface/gpu/wgsl.ts new file mode 100644 index 000000000..12def83a6 --- /dev/null +++ b/packages/ecs/src/interface/gpu/wgsl.ts @@ -0,0 +1,20 @@ +import { definitionOf } from '../registry/index.js'; +import type { Name } from '../system/name.js'; +import type { System } from '../system/system.js'; +import { accessorsOf, declarationsOf } from './accessors.js'; + +/** + * The WGSL a render system's shaders include: its bind group at `shader.group` (the heap and its table record), and a + * function per accessor returning that component of a row, such as `fn position(row: u32) -> vec3`, plus + * `ecs_count()`. Depends only on registered schemas, so pipelines can be built before any world exists. + * Throws if the system isn't a render system or a component can't be read on the GPU. + */ +export const wgsl = (system: System): string => { + const definition = definitionOf(system); + const shader = definition?.kind === "render" ? definition.declaration.shader : undefined; + if (shader === undefined) throw new Error(`System "${system.name}" isn't a render system.`); + const { name } = system; + const accessors = accessorsOf(shader); + if (typeof accessors === "string") throw new Error(`Render system "${name}": ${accessors}.`); + return declarationsOf(shader.group ?? 0, accessors); +}; diff --git a/packages/ecs/src/interface/index.ts b/packages/ecs/src/interface/index.ts new file mode 100644 index 000000000..0059d6e72 --- /dev/null +++ b/packages/ecs/src/interface/index.ts @@ -0,0 +1,19 @@ +export { Archetype } from './archetype/archetype.js'; +export { Component } from './component/component.js'; +export { Entity } from './entity/entity.js'; +export { Finalizer } from './finalizer/finalizer.js'; +export { Gpu } from './gpu/gpu.js'; +export { Index } from './indexes/index-type.js'; +export * from './math/index.js'; +export * from './resources/index.js'; +export { Report } from './report/report.js'; +export { Resource } from './resource/resource.js'; +export { Rule } from './rule/rule.js'; +export type { Schema } from './schema/schema.js'; +export { System } from './system/system.js'; +export { Trait } from './trait/trait.js'; +export type { World } from './world/world.js'; +export type { Components } from './component/types.js'; +export type { Resources } from './resource/types.js'; +export type { Traits } from './trait/types.js'; +export type { Threads } from './thread/thread.js'; diff --git a/packages/ecs/src/interface/index.type-test.ts b/packages/ecs/src/interface/index.type-test.ts new file mode 100644 index 000000000..11894649d --- /dev/null +++ b/packages/ecs/src/interface/index.type-test.ts @@ -0,0 +1,55 @@ +import { Component, Finalizer, Resource, Rule, System, gpuDevice, workers, type Gpu } from "@adobe/ecs"; +import type * as Ecs from "@adobe/ecs"; +import type { Create } from "./world/create.js"; + +// A consumer emits declarations for the values it makes from the public interface, naming no internal module. +export const position = Symbol("indexTypeTest/position"); +export const slot = Symbol("indexTypeTest/slot"); +export const element = Symbol("indexTypeTest/element"); +export const scale = Symbol("indexTypeTest/scale"); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + [slot]: { type: number; derived: true }; + [element]: { type: HTMLElement; thread: "main" }; + } + interface Resources { + [scale]: { type: number }; + } +} +Component.register(position, { type: { type: "number" } }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Component.register(element, { thread: "main" }); +Resource.register(scale, { type: { type: "number", default: 1 } }); +export const move = System.create({ name: "indexTypeTest/move", components: { write: [position] }, resources: { read: [scale] } }, (row, { resources }) => { row[position] += resources[scale]; }); +export const show = System.create({ name: "indexTypeTest/show", components: { read: [position], write: [element] } }, row => { row[element].textContent = String(row[position]); }); +export const all = System.create({ name: "indexTypeTest/all", systems: [move, show] }); +export const draw = System.render({ name: "indexTypeTest/draw", shader: { accessors: { place: position } } }, () => {}); +export const release = Finalizer.create({ name: "indexTypeTest/release", components: { read: [element] } }, row => { row[element].remove(); }); +Rule.create({ name: "indexTypeTest/slots", components: { has: [position] }, derive: [slot] }); +declare const create: Create; +declare const wgsl: typeof Gpu.wgsl; +export const world = create({ [workers]: 4, [gpuDevice]: null }); +export const read = System.create({ name: "indexTypeTest/read", observer: true, components: { read: [position, slot] }, resources: { read: [gpuDevice, workers] } }, () => {}); +export const readOne = System.create({ observer: true, lookup: { at: { read: [position], has: [slot] } } }, () => {}); +export const report = world.run(all); +export const shader = wgsl(draw); +export const added = world.add(read); + +// Only types a consumer declares with are named; the rest are inferred where wanted. +// @ts-expect-error a thread's name is keyof Threads +export type NoThread = Ecs.Thread; +// @ts-expect-error the cadence's value is Resource.Value +export type NoCadence = Ecs.Cadence; +// @ts-expect-error a rule is the value Rule.create returns: ReturnType +export const noRule: Rule = Rule.create({ components: { has: [position] }, derive: [slot] }); +// @ts-expect-error an archetype is a readonly list of components +export const noArchetype: Ecs.Archetype = [position]; +// @ts-expect-error a trait's key is keyof Traits +export type NoTraitKey = Ecs.Trait.Key; +// @ts-expect-error observer systems read a world: no Observe +export type NoObserve = Ecs.Observe; +// @ts-expect-error nor its helpers +export type NoToPromise = typeof Ecs.Observe.toPromise; +// @ts-expect-error nor what world.observe held +export type NoObservers = Ecs.Observers; diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts new file mode 100644 index 000000000..3cb48ade3 --- /dev/null +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../component/component.js"; +import { Entity } from "../entity/entity.js"; +import { Finalizer } from "../finalizer/finalizer.js"; +import { identify } from "../registry/ids.test-helper.js"; +import { isLeaf, labelOfItem, requireDefinition } from "../registry/index.js"; +import { accessOf } from "../registry/schedule.js"; +import { threadOf } from "../registry/threads.js"; +import { System } from "../system/system.js"; +import { Index } from "./index-type.js"; + +const cell = Symbol("indexCreateTest/cell"); +const point = Symbol("indexCreateTest/point"); +const amount = Symbol("indexCreateTest/amount"); +const name = Symbol("indexCreateTest/name"); +const node = Symbol("indexCreateTest/node"); +const tags = Symbol("indexCreateTest/tags"); +const shown = Symbol("indexCreateTest/shown"); +const parent = Symbol("indexCreateTest/parent"); +const unregistered = Symbol("indexCreateTest/unregistered"); +const handle = Symbol("indexCreateTest/handle"); +const view = Symbol("indexCreateTest/view"); + +declare module "@adobe/ecs" { + export interface Components { + [cell]: { type: readonly [number, number] }; + [point]: { type: { readonly x: number; readonly y: number } }; + [amount]: { type: number }; + [name]: { type: string }; + [node]: { type: { readonly label: string } }; + [tags]: { type: ReadonlySet }; + [shown]: { type: true }; + [parent]: { type: Entity; reference: "owner" }; + [handle]: { type: () => void }; + [view]: { type: { readonly label: string }; thread: "main" }; + } +} +Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); +Component.register(point, { type: { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] } }); +Component.register(amount, { type: { type: "number" } }); +Component.register(name, { type: { type: "string" } }); +Component.register(node, { type: { type: "object", properties: { label: { type: "string" } } } }); +Component.register(tags, { type: { type: "set", items: { type: "string" } } }); +Component.register(shown, { type: { const: true } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(handle, {}); +Component.register(view, { type: { type: "object", properties: { label: { type: "string" } } }, thread: "main" }); + +/** Declares as an untyped caller would, bypassing the types' checks. */ +const untyped = (declaration: object): unknown => Reflect.apply(Index.create, undefined, [declaration]); + +describe("Index.create", () => { + it("returns an index labeled by its name, its binding and id, or its kind numbered", () => { + const named = Index.create({ name: "indexCreateTest/named", key: [amount] }); + expect(named.name).toBe("indexCreateTest/named"); + const bound = Index.create(identify({ key: [amount], sorted: true }, "i.ts#bound")); + expect(bound.name).toBe("bound (i.ts#bound)"); + expect(Index.create({ key: [amount] }).name).toMatch(/^index #\d+$/); + }); + it("is the index already recorded when declared again under its id the same, and throws for another declaration", () => { + const first = Index.create(identify({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } }, "i.ts#again")); + expect(Index.create(identify({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } }, "i.ts#again"))).toBe(first); + expect(() => Index.create(identify({ name: "indexCreateTest/again", key: [amount] }, "i.ts#again"))).toThrow("reload the page"); + // Another value is another index. + expect(Index.create({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } })).not.toBe(first); + }); + it("throws for key parts that aren't components of data, are listed twice, or are sets", () => { + expect(() => untyped({ name: "bad" })).toThrow(`Index "bad" needs a key`); + expect(() => untyped({ name: "bad", key: amount })).toThrow(`Index "bad" needs a key`); + expect(() => untyped({ name: "bad", key: [unregistered] })).toThrow(`key part "indexCreateTest/unregistered", which isn't a registered component of data`); + expect(() => untyped({ name: "bad", key: [handle] })).toThrow("isn't a registered component of data"); + expect(() => untyped({ name: "bad", key: [amount, amount] })).toThrow(`lists key part "indexCreateTest/amount" twice`); + expect(() => untyped({ name: "bad", key: [tags] })).toThrow("a set or map"); + }); + it("takes an empty key, holding the entities with its has and not, and isn't unique then", () => { + expect(() => Index.create({ key: [] })).not.toThrow(); + expect(() => Index.create({ key: [], sorted: true, components: { has: [amount], not: [shown] } })).not.toThrow(); + expect(() => untyped({ name: "bad", key: [], unique: true })).toThrow(`Index "bad" is unique without a key`); + expect(() => untyped({ name: "bad", key: [], components: { has: [shown], not: [shown] } })).toThrow("which it holds by"); + expect(() => untyped({ name: "bad", key: [], components: { has: [unregistered] } })).toThrow("isn't a registered component"); + }); + it("orders sorted parts that have an order: numbers, booleans, entities, strings and tuples of them", () => { + expect(() => Index.create({ key: [cell, amount, name, parent], sorted: true })).not.toThrow(); + expect(() => untyped({ name: "bad", key: [point], sorted: true })).toThrow(`sorted by "indexCreateTest/point", whose values have no order`); + expect(() => untyped({ name: "bad", key: [node], sorted: true })).toThrow("whose values have no order"); + // A hash part may be any data. + expect(() => Index.create({ key: [point, node] })).not.toThrow(); + }); + it("throws for object parts on two threads and for has and not naming what they can't", () => { + expect(() => untyped({ name: "bad", key: [node, view] })).toThrow(`key parts on threads "world" and "main"`); + expect(() => untyped({ name: "bad", key: [name, view] })).toThrow(`key parts on threads "world" and "main"; its object parts live on one thread, the world's beside a string`); + expect(() => untyped({ name: "bad", key: [amount], components: { has: [unregistered] } })).toThrow("isn't a registered component"); + expect(() => untyped({ name: "bad", key: [amount], components: { not: [unregistered] } })).toThrow("isn't a registered component"); + expect(() => untyped({ name: "bad", key: [amount], components: { not: [amount] } })).toThrow(`excludes "indexCreateTest/amount", which it holds by`); + expect(() => untyped({ name: "bad", key: [amount], components: { has: [shown], not: [shown] } })).toThrow("which it holds by"); + }); +}); + +describe("systems and finalizers looking up through an index", () => { + const grid = Index.create({ name: "indexCreateTest/grid", key: [cell], components: { has: [amount] } }); + const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { + const reader = System.create({ name: "indexCreateTest/reader", lookup: { near: { index: grid, read: [amount] } } }, ({ lookup }) => { void lookup.near([0, 0]).at(0)[amount]; }); + const leaf = requireDefinition(reader); + expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([amount, cell]); + const writer = System.create({ name: "indexCreateTest/writer", components: { write: [cell] } }, row => { row[cell] = [0, 0]; }); + expect(() => System.create([reader, writer])).toThrow(`both access "indexCreateTest/cell" and one writes it`); + expect(() => System.create({ systems: [reader, writer], before: [] })).toThrow("order them"); + const names = Index.create({ key: [view] }); + expect(threadOf({ lookup: { shown: { index: names } } }, "System", "placed")).toBe("main"); + }); + it("throw for a lookup through what isn't an index, reading or watching what not every entity it holds has, or a system writing its key or reads", () => { + expect(() => untypedSystem({ name: "bad", lookup: { near: { index: { name: "fake" } } } })).toThrow(`lookup "near" goes through a value Index.create didn't return`); + expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, read: [name] } } })).toThrow(`lookup "near" reads "indexCreateTest/name", which not every entity index "indexCreateTest/grid" holds has`); + expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, changed: [name] } } })).toThrow(`lookup "near" watches "indexCreateTest/name"`); + expect(() => untypedSystem({ name: "bad", components: { write: [cell] }, lookup: { near: { index: grid } } })).toThrow(`goes through an index keyed by "indexCreateTest/cell", which the system writes`); + expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, lookup: { near: { index: grid, read: [amount] } } })).toThrow(`reads "indexCreateTest/amount", which the system writes`); + expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, lookup: { near: { index: Index.create({ key: [name] }) } } })).toThrow(`keyed by "indexCreateTest/name", a string, which kernels don't read yet`); + expect(() => Reflect.apply(Finalizer.create, undefined, [{ name: "bad", components: { read: [amount] }, lookup: { near: { index: grid, read: [name] } } }, () => {}])).toThrow(`Finalizer "bad": lookup "near" reads`); + expect(labelOfItem(grid)).toBe("indexCreateTest/grid"); + }); +}); + +describe("systems ordering their rows by an index", () => { + const ranked = Index.create({ name: "indexCreateTest/ranked", key: [amount], sorted: true, components: { has: [cell] } }); + const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { + const ordered = System.create({ name: "indexCreateTest/ordered", components: { read: [cell] }, order: ranked }, row => { void row[cell]; }); + const leaf = requireDefinition(ordered); + expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([cell, amount]); + const writer = System.create({ name: "indexCreateTest/rankWriter", components: { write: [amount] } }, row => { row[amount] = 0; }); + expect(() => System.create([ordered, writer])).toThrow(`both access "indexCreateTest/amount" and one writes it`); + // Every value a sorted index orders lives in shared memory, strings included, so its key places nothing. + expect(threadOf({ order: Index.create({ key: [name], sorted: true }) }, "System", "unplaced")).toBe(null); + }); + it("throw for an order by what isn't a sorted index, without components, with up, on the GPU, or writing its key", () => { + expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: { name: "fake" } })).toThrow(`System "bad" orders its rows by a value Index.create didn't return`); + expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: Index.create({ name: "indexCreateTest/hashed", key: [amount] }) })).toThrow(`orders its rows by index "indexCreateTest/hashed", which isn't sorted`); + expect(() => untypedSystem({ name: "bad", order: ranked })).toThrow("orders its rows without components"); + expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, up: { through: parent }, order: ranked })).toThrow("uses both up and order"); + expect(() => untypedSystem({ name: "bad", kernel: "gpu", components: { write: [cell] }, order: ranked })).toThrow("GPU kernels have no order yet"); + expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, order: ranked })).toThrow(`its rows are ordered by an index keyed by "indexCreateTest/amount", which the system writes`); + expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); + }); +}); diff --git a/packages/ecs/src/interface/indexes/create.ts b/packages/ecs/src/interface/indexes/create.ts new file mode 100644 index 000000000..d498f06f6 --- /dev/null +++ b/packages/ecs/src/interface/indexes/create.ts @@ -0,0 +1,80 @@ +import type { Component } from '../component/component.js'; +import { threadOfDeclaration } from '../registry/declarations.js'; +import { takeIdOfDeclaration } from '../registry/ids.js'; +import { addIndex } from '../registry/indexes.js'; +import { nameOf } from '../registry/items.js'; +import { layoutOf } from '../registry/layout.js'; +import { declarationOf, firstKeyOf, kindOf, labelOf } from '../registry/names.js'; +import type { Schema } from '../schema/schema.js'; +import type { Listed, Property } from '../system/property.js'; +import type { Created } from './created.js'; +import type { Declaration } from './declaration.js'; +import type { Index } from './index-type.js'; + +/** `not` excludes what the index holds by: its key parts and `has`. An index without a key isn't unique. */ +type Validate = { + readonly components?: { readonly not?: readonly Exclude, "has">>[] }; +} & (D["key"] extends readonly [] ? { readonly unique?: false } : unknown); + +/** Whether a schema's values have an order: numbers, booleans, entities and fixed-length tuples of them, and strings. */ +const ordered = (schema: Schema): boolean => { + if ("type" in schema && schema.type === "string") return true; + const layout = layoutOf(schema); + return layout.kind === "linear" && layout.leaves.every(leaf => leaf.path.every(step => typeof step === "number")); +}; + +/** Whether a schema's values are sets or maps, which a key part can't be yet. */ +const collection = (schema: Schema): boolean => "type" in schema && (schema.type === "set" || schema.type === "map"); + +/** + * Declares an index over components' values and returns it; nothing is built until a world's system or finalizer uses + * it. It holds the entities with every key part and every component in `has`, and none in `not`, keyed by the values + * of `key`, compared part by part: numbers and tuples by value, an entity with its generation, so a stale one finds + * nothing. With an empty key it holds those entities, every entity but the resource entity when `has` and `not` are + * empty too, and its lookup takes no key and gives them all, in entity order. + * - `sorted`: keeps key order, numbers numerically, NaN last, booleans false first, strings by code point, tuples + * element by element, then entity order, so a lookup takes a prefix of the key whose last part may be a comparison. + * - `unique`: at most one entity per key: a lookup by a whole key returns its row or null. When two entities share a + * key, the one with the lower entity index is kept, and the run that found them reports an `"index"` failure. + * + * Throws if a key part isn't a registered component of data, is listed twice or is a set or map, a sorted part has no + * order, its object parts live on different threads, or on another than the world's beside a string, which the world + * keeps its keys of, it's unique without a key, or `has` or `not` names a key that isn't a registered component + * or `not` names a key part or a `has` component. + */ +export const create = (declaration: D & Validate): Index => { + const id = takeIdOfDeclaration(declaration); + const name = nameOf(declaration.name, id, "index"); + const fail = (message: string): never => { throw new Error(`Index "${name}" ${message}.`); }; + const { key, components, sorted } = declaration; + if (!Array.isArray(key)) fail("needs a key: a list of components"); + if (key.length === 0 && declaration.unique === true) fail("is unique without a key: it holds at most one entity per key"); + const threads = new Set(); + const parts = new Set(); + for (const part of key) { + const found = declarationOf(part); + const type = found?.type; + if (kindOf(part) !== "component" || type === undefined) fail(`has key part "${labelOf(part)}", which isn't a registered component of data`); + else { + if (parts.has(firstKeyOf(part))) fail(`lists key part "${labelOf(part)}" twice`); + parts.add(firstKeyOf(part)); + if (collection(type)) fail(`has key part "${labelOf(part)}", a set or map, which a key part can't be yet`); + if (sorted === true && !ordered(type)) fail(`is sorted by "${labelOf(part)}", whose values have no order: a sorted part is a number, boolean, entity, string or fixed-length tuple of them`); + // The world's thread keeps keys of strings, so they hold the index there with its objects. + const thread = found === undefined ? null : threadOfDeclaration(found) ?? (layoutOf(type).kind === "string" ? "world" : null); + if (thread !== null) threads.add(thread); + } + } + if (threads.size > 1) fail(`has key parts on threads ${[...threads].map(thread => `"${thread}"`).join(" and ")}; its object parts live on one thread, the world's beside a string`); + const held = new Set(parts); + for (const component of components?.has ?? []) { + if (kindOf(component) !== "component") fail(`has "${labelOf(component)}", which isn't a registered component`); + held.add(firstKeyOf(component)); + } + for (const component of components?.not ?? []) { + if (kindOf(component) !== "component") fail(`excludes "${labelOf(component)}", which isn't a registered component`); + if (held.has(firstKeyOf(component))) fail(`excludes "${labelOf(component)}", which it holds by`); + } + const index: Created = { kind: "index", name, declaration }; + return addIndex(index, id); +}; diff --git a/packages/ecs/src/interface/indexes/created.ts b/packages/ecs/src/interface/indexes/created.ts new file mode 100644 index 000000000..264744a3d --- /dev/null +++ b/packages/ecs/src/interface/indexes/created.ts @@ -0,0 +1,4 @@ +import type { Declaration } from './declaration.js'; + +/** What `Index.create` records: its kind, its label and its declaration. */ +export type Created = { readonly kind: "index"; readonly name: string; readonly declaration: Declaration }; diff --git a/packages/ecs/src/interface/indexes/declaration.ts b/packages/ecs/src/interface/indexes/declaration.ts new file mode 100644 index 000000000..98f139165 --- /dev/null +++ b/packages/ecs/src/interface/indexes/declaration.ts @@ -0,0 +1,11 @@ +import type { Component } from '../component/component.js'; +import type { Name } from './name.js'; + +/** What `Index.create` takes, its fields documented there; not exported from the package. */ +export type Declaration = { + readonly name?: Name; + readonly key: readonly Component.Key[]; + readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly sorted?: boolean; + readonly unique?: boolean; +}; diff --git a/packages/ecs/src/interface/indexes/index-type.ts b/packages/ecs/src/interface/indexes/index-type.ts new file mode 100644 index 000000000..1bb0295fa --- /dev/null +++ b/packages/ecs/src/interface/indexes/index-type.ts @@ -0,0 +1,12 @@ +import type { Declaration } from './declaration.js'; + +declare const declared: unique symbol; + +/** + * An index, the value `Index.create` returned: identified by that value, labeled by its name. `D` is the type of the + * declaration it was created from, which its lookups take their key and rows from; code accepting any sorted index + * takes `Index<{ readonly sorted: true }>`. + */ +export type Index = { readonly name: string; readonly [declared]?: D }; + +export * as Index from './index.js'; diff --git a/packages/ecs/src/interface/indexes/index.ts b/packages/ecs/src/interface/indexes/index.ts new file mode 100644 index 000000000..8fbd8a7ef --- /dev/null +++ b/packages/ecs/src/interface/indexes/index.ts @@ -0,0 +1 @@ +export * from './create.js'; diff --git a/packages/ecs/src/interface/indexes/index.type-test.ts b/packages/ecs/src/interface/indexes/index.type-test.ts new file mode 100644 index 000000000..9da5a8ca5 --- /dev/null +++ b/packages/ecs/src/interface/indexes/index.type-test.ts @@ -0,0 +1,293 @@ +import { Component } from '../component/component.js'; +import { Entity } from '../entity/entity.js'; +import { Finalizer } from '../finalizer/finalizer.js'; +import { Vec3 } from '../math/index.js'; +import { System } from '../system/system.js'; +import { Index } from './index-type.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const cell = Symbol("indexTest/cell"); +const position = Symbol("indexTest/position"); +const velocity = Symbol("indexTest/velocity"); +const serverId = Symbol("indexTest/serverId"); +const health = Symbol("indexTest/health"); +const track = Symbol("indexTest/track"); +const start = Symbol("indexTest/start"); +const clip = Symbol("indexTest/clip"); +const playhead = Symbol("indexTest/playhead"); +const owner = Symbol("indexTest/owner"); +const archived = Symbol("indexTest/archived"); +const label = Symbol("indexTest/label"); + +declare module '@adobe/ecs' { + export interface Components { + [cell]: { type: Vec3.I32 }; + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + [serverId]: { type: number }; + [health]: { type: number }; + [track]: { type: Entity }; + [start]: { type: number }; + [clip]: { type: number }; + [playhead]: { type: number }; + [owner]: { type: Entity; reference: "owner" }; + [archived]: { type: true }; + [label]: { type: string }; + } +} +Component.register(cell, { type: Vec3.I32.schema }); +Component.register(position, { type: Vec3.schema }); +Component.register(velocity, { type: Vec3.schema }); +Component.register(serverId, { type: { type: "number" } }); +Component.register(health, { type: { type: "number" } }); +Component.register(track, { type: Entity.schema }); +Component.register(start, { type: { type: "number" } }); +Component.register(clip, { type: { type: "number" } }); +Component.register(playhead, { type: { type: "number" } }); +Component.register(owner, { type: Entity.schema, reference: "owner" }); +Component.register(archived, { type: { const: true } }); +Component.register(label, { type: { type: "string" } }); + +// An index is typed by its declaration, as written. +const grid = Index.create({ name: "indexTest/grid", key: [cell], components: { has: [position] } }); +check>>(); +const byServerId = Index.create({ key: [serverId], unique: true, components: { has: [health] } }); +const clips = Index.create({ key: [track, start], sorted: true, components: { has: [clip] } }); +const tasksOf = Index.create({ key: [owner], components: { not: [archived] } }); +const named = Index.create({ key: [label, serverId], sorted: true, unique: true }); +// With an empty key, an index holds the entities with every `has` component and no `not` one; with neither, every entity. +const living = Index.create({ key: [], components: { has: [health], not: [archived] } }); +check>>(); +const everything = Index.create({ key: [] }); +const inOrder = Index.create({ key: [], sorted: true, components: { has: [start] } }); +// @ts-expect-error a key is a list of components +Index.create({ key: [health, 3] }); +// @ts-expect-error an index without a key holds no key once +Index.create({ key: [], unique: true }); +// @ts-expect-error `not` excludes `has` +Index.create({ key: [], components: { has: [archived], not: [archived] } }); +// @ts-expect-error `not` excludes neither key parts nor `has` +Index.create({ key: [owner], components: { has: [archived], not: [owner] } }); + +// Code accepting any sorted index takes `Index<{ readonly sorted: true }>`. +const sortedOnly = (index: Index<{ readonly sorted: true }>): string => index.name; +sortedOnly(clips); +sortedOnly(named); +// @ts-expect-error a hash index isn't sorted +sortedOnly(grid); +const anyIndex: Index = tasksOf; +// @ts-expect-error an index whose declaration isn't known isn't known to be sorted +sortedOnly(anyIndex); + +// A hash lookup takes the whole key and gives the bucket's rows, with `length`, `changed` and `at`. +export const separate = System.create({ + components: { read: [position, cell], write: [velocity] }, + lookup: { near: { index: grid, read: [position] } }, +}, (row, { lookup }) => { + const near = lookup.near(row[cell]); + check>(); + check>(); + const other = near.at(0); + const value: Vec3 = other[position]; + const entity: Entity = other.entity; + row[velocity] = value; + void entity; + // @ts-expect-error rows are read-only + other[position] = [0, 0, 0]; + // @ts-expect-error rows hold only the lookup's read + void other[cell]; + // @ts-expect-error a hash lookup takes the whole key + lookup.near(); + // @ts-expect-error a key part's value has its type + lookup.near([0, 0]); +}); + +// A unique lookup gives the row or null. +export const applyDelta = System.create({ + input: { type: "object", properties: { id: { type: "number" }, hp: { type: "number" } } }, + lookup: { known: { index: byServerId, read: [health] } }, +}, ({ input, lookup, commands }) => { + const known = lookup.known(input.id); + check>(); + if (known === null) commands.spawn([serverId, health])({ [serverId]: input.id, [health]: input.hp }); + else commands.add(known.entity, health, known[health] + input.hp); +}); + +// A sorted lookup takes a prefix of the key, whose last part may be a comparison. +export const cue = System.create({ + components: { read: [track, playhead] }, + lookup: { due: { index: clips, read: [clip, start], changed: [clip] } }, +}, (row, { lookup }) => { + const t = row[playhead]; + const due = lookup.due(row[track], { ">=": t, "<": t + 1 }); + for (let i = 0; i < due.length; i++) void [due.at(i)[clip], due.at(i)[start]]; + void lookup.due(); + void lookup.due({ ">": row[track] }); + void lookup.due(row[track], t); + // @ts-expect-error only the last part given may be a comparison + lookup.due({ "==": row[track] }, t); +}); + +// A unique sorted lookup gives a row by a whole key of plain values, and rows by anything else. +System.create({ lookup: { names: { index: named, read: [label] } } }, ({ lookup }) => { + const one = lookup.names("a", 1); + check>(); + const some = lookup.names("a", { ">": 1 }); + check>(); + const all = lookup.names(); + void all.at(0)[label]; +}); + +// A lookup through an index without a key takes none and gives every row it holds, in entity order. +export const census = System.create({ + lookup: { living: { index: living, read: [health], changed: [health] }, all: { index: everything }, ordered: { index: inOrder, read: [start] } }, +}, ({ lookup }) => { + const rows = lookup.living(); + check>(); + check>(); + check, { readonly entity: Entity } & { readonly [health]: number }>>(); + void lookup.all().at(0).entity; + void lookup.ordered().at(0)[start]; + // @ts-expect-error a lookup without a key takes none + lookup.living(1); + // @ts-expect-error rows hold only what every entity the index holds has + void lookup.all().at(0)[health]; +}); +System.create({ + kernel: "cpu", + components: { write: [velocity] }, + lookup: { living: { index: living, read: [health] } }, +}, () => {}); +System.create({ + components: { write: [health] }, + // @ts-expect-error a system doesn't write what it reads through an index + lookup: { living: { index: living, read: [health] } }, +}, () => {}); +System.create({ components: { read: [start], write: [playhead] }, order: inOrder }, row => { + row[playhead] = row[start]; +}); + +// Finalizers look up through indexes too. +Finalizer.create({ components: { read: [owner] }, lookup: { tasks: { index: tasksOf } } }, (row, { lookup }) => { + const tasks = lookup.tasks(row[owner]); + void tasks.at(0).entity; +}); + +System.create({ + components: { read: [cell] }, + // @ts-expect-error an index lookup reads only what every entity the index holds has + lookup: { near: { index: grid, read: [velocity] } }, +}, () => {}); + +System.create({ + components: { read: [cell] }, + // @ts-expect-error an index lookup watches only what every entity the index holds has + lookup: { near: { index: grid, changed: [velocity] } }, +}, () => {}); + +System.create({ + components: { write: [cell] }, + // @ts-expect-error a system doesn't write what it finds rows by + lookup: { near: { index: grid } }, +}, () => {}); + +System.create({ + components: { write: [position] }, + // @ts-expect-error a system doesn't write what it reads through an index + lookup: { near: { index: grid, read: [position] } }, +}, () => {}); + +System.create({ + kernel: "cpu", + components: { read: [cell], write: [velocity] }, + lookup: { near: { index: grid, read: [position] } }, +}, () => {}); + +System.create({ + kernel: "cpu", + components: { read: [cell], write: [velocity] }, + lookup: { near: { index: grid, changed: [position] } }, +}, () => {}); + +System.create({ + kernel: "cpu", + components: { write: [velocity] }, + // @ts-expect-error kernels look up only through indexes of struct keys + lookup: { names: { index: named } }, +}, () => {}); + +System.create({ + kernel: "gpu", + components: { write: [velocity] }, + // @ts-expect-error GPU kernels can't use lookups + lookup: { near: { index: grid } }, +}, () => {}); + +// `order` runs a system's rows in a sorted index's order. +System.create({ components: { read: [start], write: [playhead] }, order: clips }, row => { + row[playhead] = row[start]; +}); +System.create({ kernel: "cpu", components: { read: [start], write: [playhead] }, order: clips }, row => { + row[playhead] = row[start]; +}); +// A sorted index typed by its declaration is one of `Index<{ readonly sorted: true }>`. +const ordering: Index<{ readonly sorted: true }> = clips; +void ordering; +// @ts-expect-error a hash index has no order +System.create({ components: { write: [velocity] }, order: grid }, () => {}); +// @ts-expect-error a hash index isn't one of `Index<{ readonly sorted: true }>` +const unordered: Index<{ readonly sorted: true }> = grid; +void unordered; +// @ts-expect-error a system without components runs once, in no order +System.create({ order: clips }, () => {}); +System.create({ + components: { read: [clip], write: [playhead] }, + up: { through: owner }, + // @ts-expect-error `up` orders rows too + order: clips, +}, () => {}); +System.create({ + components: { write: [start] }, + // @ts-expect-error a system doesn't write what its rows are ordered by + order: clips, +}, () => {}); +System.create({ + kernel: "gpu", + components: { write: [velocity] }, + // @ts-expect-error GPU kernels have no order yet + order: clips, +}, () => {}); +System.create({ + kernel: "cpu", + components: { write: [velocity] }, + // @ts-expect-error kernels are ordered only by indexes of struct keys + order: named, +}, () => {}); + +// An index lookup's `changed: true` runs its system only when the index's entries changed since it last ran: its +// lists' `changed` then reports entries alone. Observer systems and CPU kernels take it; it's not a list of components. +System.create({ observer: true, lookup: { living: { index: living, changed: true } } }, ({ lookup }) => { + const rows = lookup.living(); + check>(); + check>(); +}); +System.create({ components: { read: [cell] }, lookup: { near: { index: grid, read: [position], changed: true } } }, (row, { lookup }) => { + void lookup.near(row[cell]).at(0)[position]; +}); +System.create({ + kernel: "cpu", + components: { read: [cell], write: [velocity] }, + lookup: { near: { index: grid, changed: true } }, +}, () => {}); +System.create({ + // @ts-expect-error only an index lookup's `changed` may be true + lookup: { player: { read: [health], changed: true } }, +}, () => {}); +System.create({ + // @ts-expect-error `changed` is true or a list of components + lookup: { living: { index: living, changed: false } }, +}, () => {}); +// @ts-expect-error a finalizer runs on each entity that stops matching, so its lookups' `changed` is never true +Finalizer.create({ components: { read: [owner] }, lookup: { tasks: { index: tasksOf, changed: true } } }, () => {}); diff --git a/packages/ecs/src/interface/indexes/name.ts b/packages/ecs/src/interface/indexes/name.ts new file mode 100644 index 000000000..587cf579c --- /dev/null +++ b/packages/ecs/src/interface/indexes/name.ts @@ -0,0 +1,5 @@ +/** + * The label reports and errors give it, which needn't be unique: the value `Index.create` returns is its identity. + * Without one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. + */ +export type Name = string; diff --git a/packages/ecs/src/interface/math/f32/index.ts b/packages/ecs/src/interface/math/f32/index.ts new file mode 100644 index 000000000..0737147fa --- /dev/null +++ b/packages/ecs/src/interface/math/f32/index.ts @@ -0,0 +1,3 @@ +export type F32 = number; + +export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/f32/public.ts b/packages/ecs/src/interface/math/f32/public.ts new file mode 100644 index 000000000..20cdc26e1 --- /dev/null +++ b/packages/ecs/src/interface/math/f32/public.ts @@ -0,0 +1 @@ +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/f32/round-half-even.ts b/packages/ecs/src/interface/math/f32/round-half-even.ts new file mode 100644 index 000000000..a0c4c0c9d --- /dev/null +++ b/packages/ecs/src/interface/math/f32/round-half-even.ts @@ -0,0 +1,5 @@ +/** Rounds to the nearest integer, and halfway values to the even one, as WGSL's `round` does. */ +export const roundHalfEven = (x: number): number => { + const rounded = Math.round(x); + return rounded - x === 0.5 && Math.floor(rounded * 0.5) * 2 !== rounded ? rounded - 1 : rounded; +}; diff --git a/packages/ecs/src/interface/math/f32/schema.ts b/packages/ecs/src/interface/math/f32/schema.ts new file mode 100644 index 000000000..10f9e6734 --- /dev/null +++ b/packages/ecs/src/interface/math/f32/schema.ts @@ -0,0 +1,3 @@ +import type { Schema } from "../../schema/schema.js"; + +export const schema = { type: "number", precision: 1 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/i32/index.ts b/packages/ecs/src/interface/math/i32/index.ts new file mode 100644 index 000000000..166c0a857 --- /dev/null +++ b/packages/ecs/src/interface/math/i32/index.ts @@ -0,0 +1,4 @@ +/** Integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ +export type I32 = number; + +export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/i32/public.ts b/packages/ecs/src/interface/math/i32/public.ts new file mode 100644 index 000000000..b4e83702b --- /dev/null +++ b/packages/ecs/src/interface/math/i32/public.ts @@ -0,0 +1,2 @@ +export { schema } from "./schema.js"; +export { shiftRight } from "./shift-right.js"; diff --git a/packages/ecs/src/interface/math/i32/schema.ts b/packages/ecs/src/interface/math/i32/schema.ts new file mode 100644 index 000000000..1b1da8a97 --- /dev/null +++ b/packages/ecs/src/interface/math/i32/schema.ts @@ -0,0 +1,3 @@ +import type { Schema } from "../../schema/schema.js"; + +export const schema = { type: "integer", minimum: -2147483648, maximum: 2147483647 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/i32/shift-right.ts b/packages/ecs/src/interface/math/i32/shift-right.ts new file mode 100644 index 000000000..bf9a4fcd5 --- /dev/null +++ b/packages/ecs/src/interface/math/i32/shift-right.ts @@ -0,0 +1,3 @@ +import type { I32 } from "./index.js"; + +export const shiftRight = (n: I32, shift: number): I32 => n >> shift; diff --git a/packages/ecs/src/interface/math/index.ts b/packages/ecs/src/interface/math/index.ts new file mode 100644 index 000000000..8d7324630 --- /dev/null +++ b/packages/ecs/src/interface/math/index.ts @@ -0,0 +1,8 @@ +export * from "./f32/index.js"; +export * from "./i32/index.js"; +export * from "./u32/index.js"; +export * from "./vec2/index.js"; +export * from "./vec3/index.js"; +export * from "./vec4/index.js"; +export * from "./quat/index.js"; +export * from "./mat4x4/index.js"; diff --git a/packages/ecs/src/interface/math/intrinsic.ts b/packages/ecs/src/interface/math/intrinsic.ts new file mode 100644 index 000000000..ff4018489 --- /dev/null +++ b/packages/ecs/src/interface/math/intrinsic.ts @@ -0,0 +1,18 @@ +/** A WGSL value type an intrinsic takes or returns, made of one lane per scalar leaf. */ +export type WgslType = + | "f32" | "i32" | "u32" + | "vec2f" | "vec3f" | "vec4f" | "vec2i" | "vec3i" | "vec4i" | "vec2u" | "vec3u" | "vec4u" + | "mat4x4f"; + +/** + * Declares the WGSL builtin a math function matches, so GPU kernels emit `wgsl` for calls to it: a function, or with + * `form`, an operator between or before its operands. Other backends inline `implementation`, which is returned as is. + * The compiler reads the declaration from source, so it is written as literals. + */ +export const intrinsic = ( + declaration: { readonly wgsl: string; readonly form?: "infix" | "prefix"; readonly parameters: readonly WgslType[]; readonly result: WgslType }, + implementation: F, +): F => { + void declaration; + return implementation; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/add.ts b/packages/ecs/src/interface/math/mat4x4/add.ts new file mode 100644 index 000000000..52b6537ac --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/add.ts @@ -0,0 +1,9 @@ +import type { Mat4x4 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["mat4x4f", "mat4x4f"], result: "mat4x4f" }, (a: Mat4x4, b: Mat4x4): Mat4x4 => [ + a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3], + a[4] + b[4], a[5] + b[5], a[6] + b[6], a[7] + b[7], + a[8] + b[8], a[9] + b[9], a[10] + b[10], a[11] + b[11], + a[12] + b[12], a[13] + b[13], a[14] + b[14], a[15] + b[15] +]); diff --git a/packages/ecs/src/interface/math/mat4x4/determinant.ts b/packages/ecs/src/interface/math/mat4x4/determinant.ts new file mode 100644 index 000000000..70085663b --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/determinant.ts @@ -0,0 +1,26 @@ +import type { Mat4x4 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const determinant = intrinsic({ wgsl: "determinant", parameters: ["mat4x4f"], result: "f32" }, (m: Mat4x4): number => { + const [ + m00, m01, m02, m03, + m10, m11, m12, m13, + m20, m21, m22, m23, + m30, m31, m32, m33 + ] = m; + + const b00 = m00 * m11 - m01 * m10; + const b01 = m00 * m12 - m02 * m10; + const b02 = m00 * m13 - m03 * m10; + const b03 = m01 * m12 - m02 * m11; + const b04 = m01 * m13 - m03 * m11; + const b05 = m02 * m13 - m03 * m12; + const b06 = m20 * m31 - m21 * m30; + const b07 = m20 * m32 - m22 * m30; + const b08 = m20 * m33 - m23 * m30; + const b09 = m21 * m32 - m22 * m31; + const b10 = m21 * m33 - m23 * m31; + const b11 = m22 * m33 - m23 * m32; + + return b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; +}); diff --git a/packages/ecs/src/interface/math/mat4x4/identity.ts b/packages/ecs/src/interface/math/mat4x4/identity.ts new file mode 100644 index 000000000..65eb448b3 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/identity.ts @@ -0,0 +1,8 @@ +import type { Mat4x4 } from "./index.js"; + +export const identity: Mat4x4 = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 +]; diff --git a/packages/ecs/src/interface/math/mat4x4/index.ts b/packages/ecs/src/interface/math/mat4x4/index.ts new file mode 100644 index 000000000..a07ee6c28 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/index.ts @@ -0,0 +1,26 @@ + +export type Mat4x4 = readonly [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; + +/** + * 4x4 Matrix operations using column-major order (WebGPU standard). + * + * Matrix Layout: + * Matrices are stored in column-major order as flat arrays of 16 elements. + * This matches WebGPU's memory layout and shader expectations. + * + * Layout: [m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11, m12, m13, m14, m15] + * + * Which represents: + * ┌ ┐ + * │ m0 m4 m8 m12 │ (Column 0, 1, 2, 3) + * │ m1 m5 m9 m13 │ + * │ m2 m6 m10 m14 │ + * │ m3 m7 m11 m15 │ + * └ ┘ + * + * For transformation matrices (position, rotation, scale): + * - Columns 0-2 (m0-m2, m4-m6, m8-m10): Rotation/scale basis vectors (right, up, forward) + * - Column 3 (m12, m13, m14): Translation (x, y, z) + * - Row 3 (m3, m7, m11, m15): Perspective (usually 0, 0, 0, 1) + */ +export * as Mat4x4 from "./public.js"; diff --git a/packages/ecs/src/interface/math/mat4x4/inverse.ts b/packages/ecs/src/interface/math/mat4x4/inverse.ts new file mode 100644 index 000000000..a0850b420 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/inverse.ts @@ -0,0 +1,49 @@ +import type { Mat4x4 } from "./index.js"; +import { determinant } from "./determinant.js"; + +export const inverse = (m: Mat4x4): Mat4x4 => { + const det = determinant(m); + if (det === 0) { + throw new Error('Matrix is not invertible'); + } + + const [ + m00, m01, m02, m03, + m10, m11, m12, m13, + m20, m21, m22, m23, + m30, m31, m32, m33 + ] = m; + + const b00 = m00 * m11 - m01 * m10; + const b01 = m00 * m12 - m02 * m10; + const b02 = m00 * m13 - m03 * m10; + const b03 = m01 * m12 - m02 * m11; + const b04 = m01 * m13 - m03 * m11; + const b05 = m02 * m13 - m03 * m12; + const b06 = m20 * m31 - m21 * m30; + const b07 = m20 * m32 - m22 * m30; + const b08 = m20 * m33 - m23 * m30; + const b09 = m21 * m32 - m22 * m31; + const b10 = m21 * m33 - m23 * m31; + const b11 = m22 * m33 - m23 * m32; + + const invDet = 1.0 / det; + return [ + (m11 * b11 - m12 * b10 + m13 * b09) * invDet, + (m02 * b10 - m01 * b11 - m03 * b09) * invDet, + (m31 * b05 - m32 * b04 + m33 * b03) * invDet, + (m22 * b04 - m21 * b05 - m23 * b03) * invDet, + (m12 * b08 - m10 * b11 - m13 * b07) * invDet, + (m00 * b11 - m02 * b08 + m03 * b07) * invDet, + (m32 * b02 - m30 * b05 - m33 * b01) * invDet, + (m20 * b05 - m22 * b02 + m23 * b01) * invDet, + (m10 * b10 - m11 * b08 + m13 * b06) * invDet, + (m01 * b08 - m00 * b10 - m03 * b06) * invDet, + (m30 * b04 - m31 * b02 + m33 * b00) * invDet, + (m21 * b02 - m20 * b04 - m23 * b00) * invDet, + (m11 * b07 - m10 * b09 - m12 * b06) * invDet, + (m00 * b09 - m01 * b07 + m02 * b06) * invDet, + (m31 * b01 - m30 * b03 - m32 * b00) * invDet, + (m20 * b03 - m21 * b01 + m22 * b00) * invDet + ]; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/look-at.test.ts b/packages/ecs/src/interface/math/mat4x4/look-at.test.ts new file mode 100644 index 000000000..c3b5c2af1 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/look-at.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { Mat4x4 } from "./index.js"; +import { Quat } from "../quat/index.js"; + +describe("lookAt", () => { + test("rejects an up vector parallel to the view direction", () => { + expect(() => Mat4x4.lookAt([0, 0, 0], [0, 1, 0], [0, 1, 0])).toThrow("Up vector cannot be parallel to view direction"); + }); + + test("as a quaternion, stays finite for an up vector parallel to the view direction", () => { + expect(Quat.lookAt([0, 1, 0], [0, 1, 0]).every(Number.isFinite)).toBe(true); + }); +}); diff --git a/packages/ecs/src/interface/math/mat4x4/look-at.ts b/packages/ecs/src/interface/math/mat4x4/look-at.ts new file mode 100644 index 000000000..76efb15b6 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/look-at.ts @@ -0,0 +1,26 @@ +import type { Mat4x4 } from "./index.js"; +import type { Vec3 } from "../vec3/index.js"; +import { Vec3 as Vec3Namespace } from "../vec3/index.js"; +import { zero } from "./zero.js"; + +export const lookAt = (eye: Vec3, center: Vec3, up: Vec3): Mat4x4 => { + // Validate inputs + if (Vec3Namespace.length(up) === 0) throw new Error('Up vector cannot be zero'); + + const forward = Vec3Namespace.subtract(center, eye); + if (Vec3Namespace.length(forward) === 0) throw new Error('Eye and center cannot be the same position'); + + const f = Vec3Namespace.normalize(forward); + const right = Vec3Namespace.cross(f, up); + if (Vec3Namespace.length(right) === 0) throw new Error('Up vector cannot be parallel to view direction'); + const s = Vec3Namespace.normalize(right); + + const u = Vec3Namespace.normalize(Vec3Namespace.cross(s, f)); + + return [ + s[0], u[0], -f[0], 0, + s[1], u[1], -f[1], 0, + s[2], u[2], -f[2], 0, + -Vec3Namespace.dot(s, eye), -Vec3Namespace.dot(u, eye), Vec3Namespace.dot(f, eye), 1 + ]; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/multiply-vec3.ts b/packages/ecs/src/interface/math/mat4x4/multiply-vec3.ts new file mode 100644 index 000000000..cd843fa6c --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/multiply-vec3.ts @@ -0,0 +1,8 @@ +import type { Mat4x4 } from "./index.js"; +import type { Vec3 } from "../vec3/index.js"; + +export const multiplyVec3 = (m: Mat4x4, v: Vec3): Vec3 => [ + m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], + m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], + m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] +]; diff --git a/packages/ecs/src/interface/math/mat4x4/multiply-vec4.ts b/packages/ecs/src/interface/math/mat4x4/multiply-vec4.ts new file mode 100644 index 000000000..c7c9bf882 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/multiply-vec4.ts @@ -0,0 +1,10 @@ +import type { Mat4x4 } from "./index.js"; +import type { Vec4 } from "../vec4/index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const multiplyVec4 = intrinsic({ wgsl: "*", form: "infix", parameters: ["mat4x4f", "vec4f"], result: "vec4f" }, (m: Mat4x4, v: Vec4): Vec4 => [ + m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], + m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], + m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3], + m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3] +]); diff --git a/packages/ecs/src/interface/math/mat4x4/multiply.ts b/packages/ecs/src/interface/math/mat4x4/multiply.ts new file mode 100644 index 000000000..2828f86a5 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/multiply.ts @@ -0,0 +1,22 @@ +import type { Mat4x4 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +/** `a * b`: column `j` row `i` is the dot of row `i` of `a` and column `j` of `b`. */ +export const multiply = intrinsic({ wgsl: "*", form: "infix", parameters: ["mat4x4f", "mat4x4f"], result: "mat4x4f" }, (a: Mat4x4, b: Mat4x4): Mat4x4 => [ + a[0] * b[0] + a[4] * b[1] + a[8] * b[2] + a[12] * b[3], + a[1] * b[0] + a[5] * b[1] + a[9] * b[2] + a[13] * b[3], + a[2] * b[0] + a[6] * b[1] + a[10] * b[2] + a[14] * b[3], + a[3] * b[0] + a[7] * b[1] + a[11] * b[2] + a[15] * b[3], + a[0] * b[4] + a[4] * b[5] + a[8] * b[6] + a[12] * b[7], + a[1] * b[4] + a[5] * b[5] + a[9] * b[6] + a[13] * b[7], + a[2] * b[4] + a[6] * b[5] + a[10] * b[6] + a[14] * b[7], + a[3] * b[4] + a[7] * b[5] + a[11] * b[6] + a[15] * b[7], + a[0] * b[8] + a[4] * b[9] + a[8] * b[10] + a[12] * b[11], + a[1] * b[8] + a[5] * b[9] + a[9] * b[10] + a[13] * b[11], + a[2] * b[8] + a[6] * b[9] + a[10] * b[10] + a[14] * b[11], + a[3] * b[8] + a[7] * b[9] + a[11] * b[10] + a[15] * b[11], + a[0] * b[12] + a[4] * b[13] + a[8] * b[14] + a[12] * b[15], + a[1] * b[12] + a[5] * b[13] + a[9] * b[14] + a[13] * b[15], + a[2] * b[12] + a[6] * b[13] + a[10] * b[14] + a[14] * b[15], + a[3] * b[12] + a[7] * b[13] + a[11] * b[14] + a[15] * b[15], +]); diff --git a/packages/ecs/src/interface/math/mat4x4/orthographic.ts b/packages/ecs/src/interface/math/mat4x4/orthographic.ts new file mode 100644 index 000000000..720c23897 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/orthographic.ts @@ -0,0 +1,20 @@ +import type { Mat4x4 } from "./index.js"; + +export const orthographic = ( + left: number, + right: number, + bottom: number, + top: number, + near: number, + far: number +): Mat4x4 => { + const lr = 1 / (left - right); + const bt = 1 / (bottom - top); + const nf = 1 / (near - far); + return [ + -2 * lr, 0, 0, 0, + 0, -2 * bt, 0, 0, + 0, 0, 2 * nf, 0, + (left + right) * lr, (bottom + top) * bt, (far + near) * nf, 1 + ]; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/perspective.ts b/packages/ecs/src/interface/math/mat4x4/perspective.ts new file mode 100644 index 000000000..3336b34fa --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/perspective.ts @@ -0,0 +1,20 @@ +import type { Mat4x4 } from "./index.js"; + +export const perspective = (fovy: number, aspect: number, near: number, far: number): Mat4x4 => { + if (fovy <= 0) throw new Error('Field of view must be greater than 0'); + if (aspect <= 0) throw new Error('Aspect ratio must be greater than 0'); + if (near <= 0) throw new Error('Near plane must be greater than 0'); + if (far <= near) throw new Error('Far plane must be greater than near plane'); + + const f = 1.0 / Math.tan(fovy / 2); + // WebGPU clip-space convention: z_ndc ∈ [0, 1]. + // m[2][2] = far / (near - far) maps z_view = -far → z_ndc = 1 + // m[2][3] = (near*far) / (near-far) and m[3][2] = -1 maps z_view = -near → z_ndc = 0 + const fn = far / (near - far); + return [ + f / aspect, 0, 0, 0, + 0, f, 0, 0, + 0, 0, fn, -1, + 0, 0, near * fn, 0 + ]; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/public.ts b/packages/ecs/src/interface/math/mat4x4/public.ts new file mode 100644 index 000000000..b955b331a --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/public.ts @@ -0,0 +1,20 @@ +export * from "./add.js"; +export * from "./determinant.js"; +export * from "./identity.js"; +export * from "./inverse.js"; +export * from "./look-at.js"; +export * from "./multiply-vec3.js"; +export * from "./multiply-vec4.js"; +export * from "./multiply.js"; +export * from "./orthographic.js"; +export * from "./perspective.js"; +export * from "./rotation-x.js"; +export * from "./rotation-y.js"; +export * from "./rotation-z.js"; +export * from "./scale.js"; +export * from "./scaling.js"; +export * from "./subtract.js"; +export * from "./translation.js"; +export * from "./transpose.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/mat4x4/rotation-x.ts b/packages/ecs/src/interface/math/mat4x4/rotation-x.ts new file mode 100644 index 000000000..4d0363146 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/rotation-x.ts @@ -0,0 +1,12 @@ +import type { Mat4x4 } from "./index.js"; + +export const rotationX = (angle: number): Mat4x4 => { + const c = Math.cos(angle); + const s = Math.sin(angle); + return [ + 1, 0, 0, 0, + 0, c, s, 0, + 0, -s, c, 0, + 0, 0, 0, 1 + ]; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/rotation-y.ts b/packages/ecs/src/interface/math/mat4x4/rotation-y.ts new file mode 100644 index 000000000..0eb1eb768 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/rotation-y.ts @@ -0,0 +1,12 @@ +import type { Mat4x4 } from "./index.js"; + +export const rotationY = (angle: number): Mat4x4 => { + const c = Math.cos(angle); + const s = Math.sin(angle); + return [ + c, 0, -s, 0, + 0, 1, 0, 0, + s, 0, c, 0, + 0, 0, 0, 1 + ]; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/rotation-z.ts b/packages/ecs/src/interface/math/mat4x4/rotation-z.ts new file mode 100644 index 000000000..86d3e206c --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/rotation-z.ts @@ -0,0 +1,12 @@ +import type { Mat4x4 } from "./index.js"; + +export const rotationZ = (angle: number): Mat4x4 => { + const c = Math.cos(angle); + const s = Math.sin(angle); + return [ + c, s, 0, 0, + -s, c, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + ]; +}; diff --git a/packages/ecs/src/interface/math/mat4x4/rotation.test.ts b/packages/ecs/src/interface/math/mat4x4/rotation.test.ts new file mode 100644 index 000000000..d586414d4 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/rotation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { Mat4x4 } from "./index.js"; +import { Quat } from "../quat/index.js"; + +const close = (actual: readonly number[], expected: readonly number[]): void => + actual.forEach((value, index) => expect(value).toBeCloseTo(expected[index], 12)); + +describe("rotations", () => { + test("turn counterclockwise about their axis, as the axis-angle quaternion does", () => { + close(Mat4x4.rotationX(0.7), Quat.toMat4(Quat.fromAxisAngle([1, 0, 0], 0.7))); + close(Mat4x4.rotationY(0.7), Quat.toMat4(Quat.fromAxisAngle([0, 1, 0], 0.7))); + close(Mat4x4.rotationZ(0.7), Quat.toMat4(Quat.fromAxisAngle([0, 0, 1], 0.7))); + close(Mat4x4.multiplyVec3(Mat4x4.rotationY(Math.PI / 2), [1, 0, 0]), [0, 0, -1]); + close(Mat4x4.multiplyVec3(Mat4x4.rotationZ(Math.PI / 2), [1, 0, 0]), [0, 1, 0]); + }); +}); diff --git a/packages/ecs/src/interface/math/mat4x4/scale.ts b/packages/ecs/src/interface/math/mat4x4/scale.ts new file mode 100644 index 000000000..31d2a6479 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/scale.ts @@ -0,0 +1,9 @@ +import type { Mat4x4 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["mat4x4f", "f32"], result: "mat4x4f" }, (m: Mat4x4, s: number): Mat4x4 => [ + m[0] * s, m[1] * s, m[2] * s, m[3] * s, + m[4] * s, m[5] * s, m[6] * s, m[7] * s, + m[8] * s, m[9] * s, m[10] * s, m[11] * s, + m[12] * s, m[13] * s, m[14] * s, m[15] * s +]); diff --git a/packages/ecs/src/interface/math/mat4x4/scaling.ts b/packages/ecs/src/interface/math/mat4x4/scaling.ts new file mode 100644 index 000000000..553f7eadf --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/scaling.ts @@ -0,0 +1,8 @@ +import type { Mat4x4 } from "./index.js"; + +export const scaling = (x: number, y: number, z: number): Mat4x4 => [ + x, 0, 0, 0, + 0, y, 0, 0, + 0, 0, z, 0, + 0, 0, 0, 1 +]; diff --git a/packages/ecs/src/interface/math/mat4x4/schema.ts b/packages/ecs/src/interface/math/mat4x4/schema.ts new file mode 100644 index 000000000..722ed939c --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../schema/schema.js"; +import { schema as item } from "../f32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 16, maxItems: 16 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/mat4x4/subtract.ts b/packages/ecs/src/interface/math/mat4x4/subtract.ts new file mode 100644 index 000000000..a61f9ea93 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/subtract.ts @@ -0,0 +1,9 @@ +import type { Mat4x4 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["mat4x4f", "mat4x4f"], result: "mat4x4f" }, (a: Mat4x4, b: Mat4x4): Mat4x4 => [ + a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3], + a[4] - b[4], a[5] - b[5], a[6] - b[6], a[7] - b[7], + a[8] - b[8], a[9] - b[9], a[10] - b[10], a[11] - b[11], + a[12] - b[12], a[13] - b[13], a[14] - b[14], a[15] - b[15] +]); diff --git a/packages/ecs/src/interface/math/mat4x4/translation.ts b/packages/ecs/src/interface/math/mat4x4/translation.ts new file mode 100644 index 000000000..1b5d34ba1 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/translation.ts @@ -0,0 +1,8 @@ +import type { Mat4x4 } from "./index.js"; + +export const translation = (x: number, y: number, z: number): Mat4x4 => [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + x, y, z, 1 +]; diff --git a/packages/ecs/src/interface/math/mat4x4/transpose.ts b/packages/ecs/src/interface/math/mat4x4/transpose.ts new file mode 100644 index 000000000..232f63002 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/transpose.ts @@ -0,0 +1,9 @@ +import type { Mat4x4 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const transpose = intrinsic({ wgsl: "transpose", parameters: ["mat4x4f"], result: "mat4x4f" }, (m: Mat4x4): Mat4x4 => [ + m[0], m[4], m[8], m[12], + m[1], m[5], m[9], m[13], + m[2], m[6], m[10], m[14], + m[3], m[7], m[11], m[15] +]); diff --git a/packages/ecs/src/interface/math/mat4x4/zero.ts b/packages/ecs/src/interface/math/mat4x4/zero.ts new file mode 100644 index 000000000..bbd6c1231 --- /dev/null +++ b/packages/ecs/src/interface/math/mat4x4/zero.ts @@ -0,0 +1,8 @@ +import type { Mat4x4 } from "./index.js"; + +export const zero: Mat4x4 = [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0 +]; diff --git a/packages/ecs/src/interface/math/math.type-test.ts b/packages/ecs/src/interface/math/math.type-test.ts new file mode 100644 index 000000000..98ebad40b --- /dev/null +++ b/packages/ecs/src/interface/math/math.type-test.ts @@ -0,0 +1,20 @@ +import { Mat4x4, Quat, Vec2, Vec3, Vec4 } from '../index.js'; + +// Each type is also a namespace of functions; `Vec3` is `Vec3.F32`, with integer vectors nested beside it. +const position: Vec3 = [1, 2, 3]; +const float: Vec3.F32 = Vec3.F32.add(position, [1, 1, 1]); +const integer: Vec3.I32 = Vec3.I32.add([1, 2, 3], [4, 5, 6]); +const unsigned: Vec3.U32 = Vec3.U32.bitwiseAnd([7, 7, 7], [1, 2, 4]); +const length: number = Vec3.length(float); +const texel: Vec2 = Vec2.scale([1, 2], 2); +const color: Vec4 = Vec4.mix([0, 0, 0, 1], [1, 1, 1, 1], 0.5); +const rotation: Quat = Quat.fromAxisAngle([0, 0, 1], length); +const transform: Mat4x4 = Mat4x4.multiply(Mat4x4.identity, Quat.toMat4(rotation)); + +// @ts-expect-error a Vec3 has three elements +const wrong: Vec3 = [1, 2]; + +export const used = [integer, unsigned, texel, color, transform, wrong]; + +// @ts-expect-error vectors are read-only values +position[0] = 1; diff --git a/packages/ecs/src/interface/math/quat/add.ts b/packages/ecs/src/interface/math/quat/add.ts new file mode 100644 index 000000000..aff18277f --- /dev/null +++ b/packages/ecs/src/interface/math/quat/add.ts @@ -0,0 +1,9 @@ +import type { Quat } from "../index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: Quat, [x2, y2, z2, w2]: Quat): Quat => [ + x1 + x2, + y1 + y2, + z1 + z2, + w1 + w2 +]); diff --git a/packages/ecs/src/interface/math/quat/align-to.ts b/packages/ecs/src/interface/math/quat/align-to.ts new file mode 100644 index 000000000..bc3786a0f --- /dev/null +++ b/packages/ecs/src/interface/math/quat/align-to.ts @@ -0,0 +1,44 @@ +import type { Quat } from "../index.js"; +import type { Vec3 } from "../vec3/index.js"; +import { Vec3 as Vec3Namespace } from "../vec3/index.js"; +import { fromAxisAngle } from "./from-axis-angle.js"; +import { identity } from "./identity.js"; +import { multiply } from "./multiply.js"; + +/** + * Returns a quaternion that aligns the local Z-axis to the given direction via the shortest arc, + * then applies an additional twist around that aligned Z-axis. + * + * Note: This aligns the Z axis. + * + * @param direction - Target world-space direction for local Z. If zero-length, returns identity. + * @param twistRadians - Extra rotation around the aligned Z-axis, in radians. Defaults to 0. + */ +export const alignTo = (direction: Vec3, twistRadians: number = 0): Quat => { + const zAxis: Vec3 = [0, 0, 1]; + const dirLen = Vec3Namespace.length(direction); + if (dirLen === 0) return identity; + + const dir = Vec3Namespace.normalize(direction); + const dotVal = Vec3Namespace.dot(zAxis, dir); + + let base: Quat; + if (dotVal > 0.999999) { + base = identity; + } else if (dotVal < -0.999999) { + // Opposite: 180° around any axis orthogonal to Z + const orthoCandidate: Vec3 = [1, 0, 0]; + const axis = Math.abs(Vec3Namespace.dot(zAxis, orthoCandidate)) > 0.999 ? [0, 1, 0] as Vec3 : orthoCandidate; + base = fromAxisAngle(axis, Math.PI); + } else { + const axis = Vec3Namespace.normalize(Vec3Namespace.cross(zAxis, dir)); + const angle = Math.acos(dotVal); + base = fromAxisAngle(axis, angle); + } + + if (twistRadians === 0) return base; + + // Twist around the final aligned Z (which is 'dir' in world space) + const twist = fromAxisAngle(dir, twistRadians); + return multiply(twist, base); +}; diff --git a/packages/ecs/src/interface/math/quat/conjugate.ts b/packages/ecs/src/interface/math/quat/conjugate.ts new file mode 100644 index 000000000..b03fdcb9c --- /dev/null +++ b/packages/ecs/src/interface/math/quat/conjugate.ts @@ -0,0 +1,3 @@ +import type { Quat } from "../index.js"; + +export const conjugate = ([x, y, z, w]: Quat): Quat => [-x, -y, -z, w]; diff --git a/packages/ecs/src/interface/math/quat/distance.ts b/packages/ecs/src/interface/math/quat/distance.ts new file mode 100644 index 000000000..7dc3ee525 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/distance.ts @@ -0,0 +1,5 @@ +import type { Quat } from "../index.js"; +import { length } from "./length.js"; +import { subtract } from "./subtract.js"; + +export const distance = (a: Quat, b: Quat): number => length(subtract(b, a)); diff --git a/packages/ecs/src/interface/math/quat/dot.ts b/packages/ecs/src/interface/math/quat/dot.ts new file mode 100644 index 000000000..2e053ef84 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/dot.ts @@ -0,0 +1,5 @@ +import type { Quat } from "../index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const dot = intrinsic({ wgsl: "dot", parameters: ["vec4f", "vec4f"], result: "f32" }, ([x1, y1, z1, w1]: Quat, [x2, y2, z2, w2]: Quat): number => + x1 * x2 + y1 * y2 + z1 * z2 + w1 * w2); diff --git a/packages/ecs/src/interface/math/quat/from-axis-angle.ts b/packages/ecs/src/interface/math/quat/from-axis-angle.ts new file mode 100644 index 000000000..a58243dd9 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/from-axis-angle.ts @@ -0,0 +1,14 @@ +import type { Quat } from "../index.js"; +import type { Vec3 } from "../vec3/index.js"; +import { identity } from "./identity.js"; + +export const fromAxisAngle = (axis: Vec3, angle: number): Quat => { + const halfAngle = angle * 0.5; + const s = Math.sin(halfAngle); + const c = Math.cos(halfAngle); + // Normalize the axis to ensure a unit quaternion + const len = Math.sqrt(axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]); + if (len === 0) return identity; + const invLen = 1 / len; + return [axis[0] * invLen * s, axis[1] * invLen * s, axis[2] * invLen * s, c]; +}; diff --git a/packages/ecs/src/interface/math/quat/from-euler.ts b/packages/ecs/src/interface/math/quat/from-euler.ts new file mode 100644 index 000000000..10eaad3b7 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/from-euler.ts @@ -0,0 +1,17 @@ +import type { Quat } from "../index.js"; + +export const fromEuler = (x: number, y: number, z: number): Quat => { + const cx = Math.cos(x * 0.5); + const sx = Math.sin(x * 0.5); + const cy = Math.cos(y * 0.5); + const sy = Math.sin(y * 0.5); + const cz = Math.cos(z * 0.5); + const sz = Math.sin(z * 0.5); + + return [ + sx * cy * cz - cx * sy * sz, + cx * sy * cz + sx * cy * sz, + cx * cy * sz - sx * sy * cz, + cx * cy * cz + sx * sy * sz + ]; +}; diff --git a/packages/ecs/src/interface/math/quat/identity.ts b/packages/ecs/src/interface/math/quat/identity.ts new file mode 100644 index 000000000..a8eecb747 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/identity.ts @@ -0,0 +1,3 @@ +import type { Quat } from "../index.js"; + +export const identity: Quat = [0, 0, 0, 1]; diff --git a/packages/ecs/src/interface/math/quat/index.ts b/packages/ecs/src/interface/math/quat/index.ts new file mode 100644 index 000000000..2afd0c50e --- /dev/null +++ b/packages/ecs/src/interface/math/quat/index.ts @@ -0,0 +1,3 @@ +export type Quat = readonly [number, number, number, number]; + +export * as Quat from "./public.js"; diff --git a/packages/ecs/src/interface/math/quat/inverse.ts b/packages/ecs/src/interface/math/quat/inverse.ts new file mode 100644 index 000000000..6e21da2a4 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/inverse.ts @@ -0,0 +1,10 @@ +import type { Quat } from "../index.js"; +import { conjugate } from "./conjugate.js"; +import { lengthSquared } from "./length-squared.js"; +import { scale } from "./scale.js"; + +export const inverse = (q: Quat): Quat => { + const lenSq = lengthSquared(q); + if (lenSq === 0) return [0, 0, 0, 1]; + return scale(conjugate(q), 1 / lenSq); +}; diff --git a/packages/ecs/src/interface/math/quat/length-squared.ts b/packages/ecs/src/interface/math/quat/length-squared.ts new file mode 100644 index 000000000..2ff099398 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/length-squared.ts @@ -0,0 +1,3 @@ +import type { Quat } from "../index.js"; + +export const lengthSquared = ([x, y, z, w]: Quat): number => x * x + y * y + z * z + w * w; diff --git a/packages/ecs/src/interface/math/quat/length.ts b/packages/ecs/src/interface/math/quat/length.ts new file mode 100644 index 000000000..491234b80 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/length.ts @@ -0,0 +1,4 @@ +import type { Quat } from "../index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const length = intrinsic({ wgsl: "length", parameters: ["vec4f"], result: "f32" }, ([x, y, z, w]: Quat): number => Math.sqrt(x * x + y * y + z * z + w * w)); diff --git a/packages/ecs/src/interface/math/quat/lerp.ts b/packages/ecs/src/interface/math/quat/lerp.ts new file mode 100644 index 000000000..18c9f0f94 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/lerp.ts @@ -0,0 +1,8 @@ +import type { Quat } from "../index.js"; +import { add } from "./add.js"; +import { normalize } from "./normalize.js"; +import { scale } from "./scale.js"; + +export const lerp = (q1: Quat, q2: Quat, t: number): Quat => { + return normalize(add(scale(q1, 1 - t), scale(q2, t))); +}; diff --git a/packages/ecs/src/interface/math/quat/look-at.ts b/packages/ecs/src/interface/math/quat/look-at.ts new file mode 100644 index 000000000..23d548c21 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/look-at.ts @@ -0,0 +1,52 @@ +import type { Quat } from "../index.js"; +import type { Vec3 } from "../vec3/index.js"; +import { Vec3 as Vec3Namespace } from "../vec3/index.js"; + +export const lookAt = (forward: Vec3, up: Vec3): Quat => { + // Normalize forward vector + const f = Vec3Namespace.length(forward) === 0 ? Vec3Namespace.zero : Vec3Namespace.normalize(forward); + + // Calculate right vector; it is zero when up is parallel to forward + const right = Vec3Namespace.cross(f, up); + const r = Vec3Namespace.length(right) === 0 ? Vec3Namespace.zero : Vec3Namespace.normalize(right); + + // Recalculate up vector + const u = Vec3Namespace.cross(r, f); + + // Convert to quaternion + const trace = r[0] + u[1] + f[2]; + + if (trace > 0) { + const s = Math.sqrt(trace + 1) * 2; + return [ + (u[2] - f[1]) / s, + (f[0] - r[2]) / s, + (r[1] - u[0]) / s, + 0.25 * s + ]; + } else if (r[0] > u[1] && r[0] > f[2]) { + const s = Math.sqrt(1 + r[0] - u[1] - f[2]) * 2; + return [ + 0.25 * s, + (u[0] + r[1]) / s, + (f[0] + r[2]) / s, + (u[2] - f[1]) / s + ]; + } else if (u[1] > f[2]) { + const s = Math.sqrt(1 + u[1] - r[0] - f[2]) * 2; + return [ + (u[0] + r[1]) / s, + 0.25 * s, + (f[1] + u[2]) / s, + (f[0] - r[2]) / s + ]; + } else { + const s = Math.sqrt(1 + f[2] - r[0] - u[1]) * 2; + return [ + (f[0] + r[2]) / s, + (f[1] + u[2]) / s, + 0.25 * s, + (r[1] - u[0]) / s + ]; + } +}; diff --git a/packages/ecs/src/interface/math/quat/multiply.ts b/packages/ecs/src/interface/math/quat/multiply.ts new file mode 100644 index 000000000..953478194 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/multiply.ts @@ -0,0 +1,8 @@ +import type { Quat } from "../index.js"; + +export const multiply = ([x1, y1, z1, w1]: Quat, [x2, y2, z2, w2]: Quat): Quat => [ + x1 * w2 + w1 * x2 + y1 * z2 - z1 * y2, + y1 * w2 + w1 * y2 + z1 * x2 - x1 * z2, + z1 * w2 + w1 * z2 + x1 * y2 - y1 * x2, + w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2 +]; diff --git a/packages/ecs/src/interface/math/quat/negate.ts b/packages/ecs/src/interface/math/quat/negate.ts new file mode 100644 index 000000000..66ad089df --- /dev/null +++ b/packages/ecs/src/interface/math/quat/negate.ts @@ -0,0 +1,4 @@ +import type { Quat } from "../index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const negate = intrinsic({ wgsl: "-", form: "prefix", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: Quat): Quat => [-x, -y, -z, -w]); diff --git a/packages/ecs/src/interface/math/quat/normalize.ts b/packages/ecs/src/interface/math/quat/normalize.ts new file mode 100644 index 000000000..a316c2235 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/normalize.ts @@ -0,0 +1,8 @@ +import type { Quat } from "../index.js"; +import { length } from "./length.js"; +import { scale } from "./scale.js"; + +export const normalize = (q: Quat): Quat => { + const len = length(q); + return len === 0 ? [0, 0, 0, 1] : scale(q, 1 / len); +}; diff --git a/packages/ecs/src/interface/math/quat/public.ts b/packages/ecs/src/interface/math/quat/public.ts new file mode 100644 index 000000000..73d662aa3 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/public.ts @@ -0,0 +1,24 @@ +export * from "./add.js"; +export * from "./align-to.js"; +export * from "./conjugate.js"; +export * from "./distance.js"; +export * from "./dot.js"; +export * from "./from-axis-angle.js"; +export * from "./from-euler.js"; +export * from "./identity.js"; +export * from "./inverse.js"; +export * from "./length-squared.js"; +export * from "./length.js"; +export * from "./lerp.js"; +export * from "./look-at.js"; +export * from "./multiply.js"; +export * from "./negate.js"; +export * from "./normalize.js"; +export * from "./rotate-vec3.js"; +export * from "./scale.js"; +export * from "./slerp.js"; +export * from "./subtract.js"; +export * from "./to-axis-angle.js"; +export * from "./to-euler.js"; +export * from "./to-mat4.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/quat/rotate-vec3.ts b/packages/ecs/src/interface/math/quat/rotate-vec3.ts new file mode 100644 index 000000000..4888da4bb --- /dev/null +++ b/packages/ecs/src/interface/math/quat/rotate-vec3.ts @@ -0,0 +1,16 @@ +import type { Quat } from "../index.js"; +import type { Vec3 } from "../vec3/index.js"; +import { Vec3 as Vec3Namespace } from "../vec3/index.js"; + +export const rotateVec3 = ([x, y, z, w]: Quat, v: Vec3): Vec3 => { + // q * v * q^-1 + const qv: Vec3 = [x, y, z]; + const uv = Vec3Namespace.cross(qv, v); + const uuv = Vec3Namespace.cross(qv, uv); + const scaleFactor = 2 * w; + return [ + v[0] + scaleFactor * uv[0] + 2 * uuv[0], + v[1] + scaleFactor * uv[1] + 2 * uuv[1], + v[2] + scaleFactor * uv[2] + 2 * uuv[2] + ]; +}; diff --git a/packages/ecs/src/interface/math/quat/scale.ts b/packages/ecs/src/interface/math/quat/scale.ts new file mode 100644 index 000000000..6f5835da3 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/scale.ts @@ -0,0 +1,4 @@ +import type { Quat } from "../index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec4f", "f32"], result: "vec4f" }, ([x, y, z, w]: Quat, s: number): Quat => [x * s, y * s, z * s, w * s]); diff --git a/packages/ecs/src/interface/math/quat/schema.ts b/packages/ecs/src/interface/math/quat/schema.ts new file mode 100644 index 000000000..d7a0afa1a --- /dev/null +++ b/packages/ecs/src/interface/math/quat/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../schema/schema.js"; +import { schema as item } from "../f32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 4, maxItems: 4 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/quat/slerp.ts b/packages/ecs/src/interface/math/quat/slerp.ts new file mode 100644 index 000000000..a95114663 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/slerp.ts @@ -0,0 +1,26 @@ +import type { Quat } from "../index.js"; +import { add } from "./add.js"; +import { dot } from "./dot.js"; +import { negate } from "./negate.js"; +import { normalize } from "./normalize.js"; +import { scale } from "./scale.js"; + +export const slerp = (q1: Quat, q2: Quat, t: number): Quat => { + const dotProduct = dot(q1, q2); + + // If the dot product is negative, slerp won't take the shorter route + const q2Adjusted = dotProduct < 0 ? negate(q2) : q2; + const adjustedDot = Math.abs(dotProduct); + + // If the inputs are too close for comfort, linearly interpolate + if (adjustedDot > 0.9995) { + return normalize(add(scale(q1, 1 - t), scale(q2Adjusted, t))); + } + + const theta = Math.acos(adjustedDot); + const sinTheta = Math.sin(theta); + const factor1 = Math.sin((1 - t) * theta) / sinTheta; + const factor2 = Math.sin(t * theta) / sinTheta; + + return normalize(add(scale(q1, factor1), scale(q2Adjusted, factor2))); +}; diff --git a/packages/ecs/src/interface/math/quat/subtract.ts b/packages/ecs/src/interface/math/quat/subtract.ts new file mode 100644 index 000000000..450855e84 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/subtract.ts @@ -0,0 +1,9 @@ +import type { Quat } from "../index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: Quat, [x2, y2, z2, w2]: Quat): Quat => [ + x1 - x2, + y1 - y2, + z1 - z2, + w1 - w2 +]); diff --git a/packages/ecs/src/interface/math/quat/to-axis-angle.ts b/packages/ecs/src/interface/math/quat/to-axis-angle.ts new file mode 100644 index 000000000..932de09b8 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/to-axis-angle.ts @@ -0,0 +1,14 @@ +import type { Quat } from "../index.js"; +import type { Vec3 } from "../vec3/index.js"; + +export const toAxisAngle = ([x, y, z, w]: Quat): { axis: Vec3; angle: number } => { + const angle = 2 * Math.acos(Math.abs(w)); + const s = Math.sin(angle * 0.5); + if (s === 0) { + return { axis: [0, 0, 1], angle: 0 }; + } + return { + axis: [x / s, y / s, z / s], + angle: w < 0 ? -angle : angle + }; +}; diff --git a/packages/ecs/src/interface/math/quat/to-euler.ts b/packages/ecs/src/interface/math/quat/to-euler.ts new file mode 100644 index 000000000..1fde23b3a --- /dev/null +++ b/packages/ecs/src/interface/math/quat/to-euler.ts @@ -0,0 +1,25 @@ +import type { Quat } from "../index.js"; +import type { Vec3 } from "../vec3/index.js"; + +export const toEuler = ([x, y, z, w]: Quat): Vec3 => { + // Roll (x-axis rotation) + const sinr_cosp = 2 * (w * x + y * z); + const cosr_cosp = 1 - 2 * (x * x + y * y); + const roll = Math.atan2(sinr_cosp, cosr_cosp); + + // Pitch (y-axis rotation) + const sinp = 2 * (w * y - z * x); + let pitch; + if (Math.abs(sinp) >= 1) { + pitch = Math.sign(sinp) * Math.PI / 2; // use 90 degrees if out of range + } else { + pitch = Math.asin(sinp); + } + + // Yaw (z-axis rotation) + const siny_cosp = 2 * (w * z + x * y); + const cosy_cosp = 1 - 2 * (y * y + z * z); + const yaw = Math.atan2(siny_cosp, cosy_cosp); + + return [roll, pitch, yaw]; +}; diff --git a/packages/ecs/src/interface/math/quat/to-mat4.ts b/packages/ecs/src/interface/math/quat/to-mat4.ts new file mode 100644 index 000000000..3c4e339c7 --- /dev/null +++ b/packages/ecs/src/interface/math/quat/to-mat4.ts @@ -0,0 +1,27 @@ +import type { Quat } from "../index.js"; +import type { Mat4x4 } from "../mat4x4/index.js"; + +/** + * Converts a quaternion to a 4x4 rotation matrix in column-major order. + * The resulting matrix is compatible with WebGPU and can be directly used + * with Mat4x4 operations. + */ +export const toMat4 = ([x, y, z, w]: Quat): Mat4x4 => { + const xx = x * x; + const yy = y * y; + const zz = z * z; + const xy = x * y; + const xz = x * z; + const yz = y * z; + const wx = w * x; + const wy = w * y; + const wz = w * z; + + // Column-major format (WebGPU standard) + return [ + 1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0, + 2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0, + 2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0, + 0, 0, 0, 1 + ]; +}; diff --git a/packages/ecs/src/interface/math/semantics.test.ts b/packages/ecs/src/interface/math/semantics.test.ts new file mode 100644 index 000000000..88ce717d7 --- /dev/null +++ b/packages/ecs/src/interface/math/semantics.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { U32, Vec2, Vec3, Vec4 } from "./index.js"; + +/** The bit-at-a-time definitions the loop-free versions must agree with. */ +const reference = { + countOneBits: (n: number): number => [...(n >>> 0).toString(2)].filter(bit => bit === "1").length, + countTrailingZeros: (n: number): number => (n >>> 0) === 0 ? 32 : (n >>> 0).toString(2).length - 1 - (n >>> 0).toString(2).lastIndexOf("1"), + reverseBits: (n: number): number => parseInt([...(n >>> 0).toString(2).padStart(32, "0")].reverse().join(""), 2), +}; + +describe("math follows WGSL", () => { + test("bit counts and reversal agree with their bitwise definitions", () => { + let seed = 12345; + const values = [0, 1, 2, 3, 0x80000000, 0xFFFFFFFF, 0x7FFFFFFF, 0x55555555, -1, -(2 ** 31)]; + for (let i = 0; i < 1000; i++) values.push(seed = (Math.imul(seed, 1103515245) + 12345) >>> 0); + for (const value of values) { + expect(U32.countOneBits(value)).toBe(reference.countOneBits(value)); + expect(U32.countTrailingZeros(value)).toBe(reference.countTrailingZeros(value)); + expect(U32.reverseBits(value)).toBe(reference.reverseBits(value)); + } + expect(Vec3.I32.reverseBits([1, 0, 0])).toEqual([-(2 ** 31), 0, 0]); + }); + + test("round sends halfway values to the even integer", () => { + expect(Vec4.round([0.5, 1.5, 2.5, -2.5])).toEqual([0, 2, 2, -2]); + expect(Vec3.round([-0.5, 0.49999999999999994, 2.6])).toEqual([-0, 0, 3]); + expect(Vec2.round([1e300, -1.5])).toEqual([1e300, -2]); + }); + + test("mod is x - m * floor(x / m)", () => { + expect(Vec3.mod([5.5, -1, -7], 3)).toEqual([2.5, 2, 2]); + expect(Vec2.mod([1, -1], -3)).toEqual([-2, -1]); + }); + + test("integer vectors wrap", () => { + expect(Vec3.I32.add([2 ** 31 - 1, -(2 ** 31), 0], [1, -1, 0])).toEqual([-(2 ** 31), 2 ** 31 - 1, 0]); + expect(Vec3.I32.subtract([-(2 ** 31), 0, 0], [1, 0, 0])).toEqual([2 ** 31 - 1, 0, 0]); + expect(Vec3.I32.multiply([65536, 3, 0], [65536, -4, 0])).toEqual([0, -12, 0]); + expect(Vec3.I32.negate([-(2 ** 31), 1, 0])).toEqual([-(2 ** 31), -1, 0]); + expect(Vec3.I32.abs([-(2 ** 31), -5, 0])).toEqual([-(2 ** 31), 5, 0]); + // Products above 2 ** 53 keep their low bits. + expect(Vec3.U32.multiply([0xFFFFFFFF, 0x10001, 0], [0xFFFFFFFF, 0x10001, 0])).toEqual([1, 0x20001, 0]); + expect(Vec3.U32.volume([0xFFFFFFFF, 0xFFFFFFFF, 3])).toBe(3); + }); + + test("normalize is scale by the inverse length", () => { + expect(Vec3.normalize([0, 0, 2])).toEqual([0, 0, 1]); + expect(Vec3.normalize([0, 0, 0]).every(Number.isNaN)).toBe(true); + }); +}); diff --git a/packages/ecs/src/interface/math/u32/count-leading-zeros.ts b/packages/ecs/src/interface/math/u32/count-leading-zeros.ts new file mode 100644 index 000000000..8ef9fa53b --- /dev/null +++ b/packages/ecs/src/interface/math/u32/count-leading-zeros.ts @@ -0,0 +1,4 @@ +import type { U32 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const countLeadingZeros = intrinsic({ wgsl: "countLeadingZeros", parameters: ["u32"], result: "u32" }, (n: U32): U32 => Math.clz32(n >>> 0)); diff --git a/packages/ecs/src/interface/math/u32/count-one-bits.ts b/packages/ecs/src/interface/math/u32/count-one-bits.ts new file mode 100644 index 000000000..4ca97cfcc --- /dev/null +++ b/packages/ecs/src/interface/math/u32/count-one-bits.ts @@ -0,0 +1,10 @@ +import type { U32 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const countOneBits = intrinsic({ wgsl: "countOneBits", parameters: ["u32"], result: "u32" }, (n: U32): U32 => { + let x = n >>> 0; + x = x - ((x >>> 1) & 0x55555555); + x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); + x = (x + (x >>> 4)) & 0x0f0f0f0f; + return Math.imul(x, 0x01010101) >>> 24; +}); diff --git a/packages/ecs/src/interface/math/u32/count-trailing-zeros.ts b/packages/ecs/src/interface/math/u32/count-trailing-zeros.ts new file mode 100644 index 000000000..45eb1ab7c --- /dev/null +++ b/packages/ecs/src/interface/math/u32/count-trailing-zeros.ts @@ -0,0 +1,7 @@ +import type { U32 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const countTrailingZeros = intrinsic({ wgsl: "countTrailingZeros", parameters: ["u32"], result: "u32" }, (n: U32): U32 => { + const v = n >>> 0; + return v === 0 ? 32 : 31 - Math.clz32(v & -v); +}); diff --git a/packages/ecs/src/interface/math/u32/index.ts b/packages/ecs/src/interface/math/u32/index.ts new file mode 100644 index 000000000..1f7b92ef6 --- /dev/null +++ b/packages/ecs/src/interface/math/u32/index.ts @@ -0,0 +1,4 @@ +/** Integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ +export type U32 = number; + +export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/u32/public.ts b/packages/ecs/src/interface/math/u32/public.ts new file mode 100644 index 000000000..6456ffac4 --- /dev/null +++ b/packages/ecs/src/interface/math/u32/public.ts @@ -0,0 +1,6 @@ +export { schema } from "./schema.js"; +export { countLeadingZeros } from "./count-leading-zeros.js"; +export { countTrailingZeros } from "./count-trailing-zeros.js"; +export { countOneBits } from "./count-one-bits.js"; +export { reverseBits } from "./reverse-bits.js"; +export { shiftRight } from "./shift-right.js"; diff --git a/packages/ecs/src/interface/math/u32/reverse-bits.ts b/packages/ecs/src/interface/math/u32/reverse-bits.ts new file mode 100644 index 000000000..f7e22b487 --- /dev/null +++ b/packages/ecs/src/interface/math/u32/reverse-bits.ts @@ -0,0 +1,11 @@ +import type { U32 } from "./index.js"; +import { intrinsic } from "../intrinsic.js"; + +export const reverseBits = intrinsic({ wgsl: "reverseBits", parameters: ["u32"], result: "u32" }, (n: U32): U32 => { + let x = n >>> 0; + x = ((x >>> 1) & 0x55555555) | ((x & 0x55555555) << 1); + x = ((x >>> 2) & 0x33333333) | ((x & 0x33333333) << 2); + x = ((x >>> 4) & 0x0f0f0f0f) | ((x & 0x0f0f0f0f) << 4); + x = ((x >>> 8) & 0x00ff00ff) | ((x & 0x00ff00ff) << 8); + return ((x >>> 16) | (x << 16)) >>> 0; +}); diff --git a/packages/ecs/src/interface/math/u32/schema.ts b/packages/ecs/src/interface/math/u32/schema.ts new file mode 100644 index 000000000..fd9d75284 --- /dev/null +++ b/packages/ecs/src/interface/math/u32/schema.ts @@ -0,0 +1,3 @@ +import type { Schema } from "../../schema/schema.js"; + +export const schema = { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/u32/shift-right.ts b/packages/ecs/src/interface/math/u32/shift-right.ts new file mode 100644 index 000000000..7b45f0e09 --- /dev/null +++ b/packages/ecs/src/interface/math/u32/shift-right.ts @@ -0,0 +1,3 @@ +import type { U32 } from "./index.js"; + +export const shiftRight = (n: U32, shift: number): U32 => (n >>> shift) >>> 0; diff --git a/packages/ecs/src/interface/math/vec2/f32/abs.ts b/packages/ecs/src/interface/math/vec2/f32/abs.ts new file mode 100644 index 000000000..a203aaf9a --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/abs.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const abs = intrinsic({ wgsl: "abs", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.abs(x), Math.abs(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/acos.ts b/packages/ecs/src/interface/math/vec2/f32/acos.ts new file mode 100644 index 000000000..71e330649 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/acos.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const acos = intrinsic({ wgsl: "acos", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.acos(x), Math.acos(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/acosh.ts b/packages/ecs/src/interface/math/vec2/f32/acosh.ts new file mode 100644 index 000000000..dbcaba733 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/acosh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const acosh = intrinsic({ wgsl: "acosh", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.acosh(x), Math.acosh(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/add.ts b/packages/ecs/src/interface/math/vec2/f32/add.ts new file mode 100644 index 000000000..f467ebce9 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/add.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec2f", "vec2f"], result: "vec2f" }, ([x1, y1]: F32, [x2, y2]: F32): F32 => [x1 + x2, y1 + y2]); diff --git a/packages/ecs/src/interface/math/vec2/f32/asin.ts b/packages/ecs/src/interface/math/vec2/f32/asin.ts new file mode 100644 index 000000000..e8fc2619a --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/asin.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const asin = intrinsic({ wgsl: "asin", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.asin(x), Math.asin(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/asinh.ts b/packages/ecs/src/interface/math/vec2/f32/asinh.ts new file mode 100644 index 000000000..63b14436a --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/asinh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const asinh = intrinsic({ wgsl: "asinh", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.asinh(x), Math.asinh(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/atan.ts b/packages/ecs/src/interface/math/vec2/f32/atan.ts new file mode 100644 index 000000000..522dd5697 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/atan.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const atan = intrinsic({ wgsl: "atan", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.atan(x), Math.atan(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/atanh.ts b/packages/ecs/src/interface/math/vec2/f32/atanh.ts new file mode 100644 index 000000000..e8a879e31 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/atanh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const atanh = intrinsic({ wgsl: "atanh", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.atanh(x), Math.atanh(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/ceil.ts b/packages/ecs/src/interface/math/vec2/f32/ceil.ts new file mode 100644 index 000000000..08c953967 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/ceil.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const ceil = intrinsic({ wgsl: "ceil", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.ceil(x), Math.ceil(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/clamp.ts b/packages/ecs/src/interface/math/vec2/f32/clamp.ts new file mode 100644 index 000000000..8b4b71206 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/clamp.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec2f", "vec2f", "vec2f"], result: "vec2f" }, (v: F32, minVec: F32, maxVec: F32): F32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec2/f32/cos.ts b/packages/ecs/src/interface/math/vec2/f32/cos.ts new file mode 100644 index 000000000..c980ec3a2 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/cos.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const cos = intrinsic({ wgsl: "cos", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.cos(x), Math.cos(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/cosh.ts b/packages/ecs/src/interface/math/vec2/f32/cosh.ts new file mode 100644 index 000000000..b5b5ffb30 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/cosh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const cosh = intrinsic({ wgsl: "cosh", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.cosh(x), Math.cosh(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/distance.ts b/packages/ecs/src/interface/math/vec2/f32/distance.ts new file mode 100644 index 000000000..a1e708135 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/distance.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { length } from "./length.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const distance = intrinsic({ wgsl: "distance", parameters: ["vec2f", "vec2f"], result: "f32" }, (a: F32, b: F32): number => length(subtract(b, a))); diff --git a/packages/ecs/src/interface/math/vec2/f32/dot.ts b/packages/ecs/src/interface/math/vec2/f32/dot.ts new file mode 100644 index 000000000..ee95a5ad1 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/dot.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const dot = intrinsic({ wgsl: "dot", parameters: ["vec2f", "vec2f"], result: "f32" }, ([x1, y1]: F32, [x2, y2]: F32): number => x1 * x2 + y1 * y2); diff --git a/packages/ecs/src/interface/math/vec2/f32/equals.ts b/packages/ecs/src/interface/math/vec2/f32/equals.ts new file mode 100644 index 000000000..5839cbaf0 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/equals.ts @@ -0,0 +1,3 @@ +import type { F32 } from "./f32.js"; + +export const equals = ([x1, y1]: F32, [x2, y2]: F32): boolean => x1 === x2 && y1 === y2; diff --git a/packages/ecs/src/interface/math/vec2/f32/exp.ts b/packages/ecs/src/interface/math/vec2/f32/exp.ts new file mode 100644 index 000000000..8f4a23026 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/exp.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const exp = intrinsic({ wgsl: "exp", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.exp(x), Math.exp(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/exp2.ts b/packages/ecs/src/interface/math/vec2/f32/exp2.ts new file mode 100644 index 000000000..1342b2980 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/exp2.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const exp2 = intrinsic({ wgsl: "exp2", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.pow(2, x), Math.pow(2, y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/f32.ts b/packages/ecs/src/interface/math/vec2/f32/f32.ts new file mode 100644 index 000000000..b0f72f15b --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/f32.ts @@ -0,0 +1,3 @@ +export type F32 = readonly [number, number]; + +export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/f32/faceforward.ts b/packages/ecs/src/interface/math/vec2/f32/faceforward.ts new file mode 100644 index 000000000..107acb687 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/faceforward.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { negate } from "./negate.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const faceforward = intrinsic({ wgsl: "faceForward", parameters: ["vec2f", "vec2f", "vec2f"], result: "vec2f" }, (n: F32, i: F32, nref: F32): F32 => + dot(nref, i) < 0 ? n : negate(n)); diff --git a/packages/ecs/src/interface/math/vec2/f32/floor.ts b/packages/ecs/src/interface/math/vec2/f32/floor.ts new file mode 100644 index 000000000..e15250352 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/floor.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const floor = intrinsic({ wgsl: "floor", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.floor(x), Math.floor(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/fract.ts b/packages/ecs/src/interface/math/vec2/f32/fract.ts new file mode 100644 index 000000000..01f3b1c3e --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/fract.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const fract = intrinsic({ wgsl: "fract", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [x - Math.floor(x), y - Math.floor(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/length.ts b/packages/ecs/src/interface/math/vec2/f32/length.ts new file mode 100644 index 000000000..ab0cc5ba7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/length.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const length = intrinsic({ wgsl: "length", parameters: ["vec2f"], result: "f32" }, ([x, y]: F32): number => Math.sqrt(x * x + y * y)); diff --git a/packages/ecs/src/interface/math/vec2/f32/log.ts b/packages/ecs/src/interface/math/vec2/f32/log.ts new file mode 100644 index 000000000..d4a1fce1c --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/log.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const log = intrinsic({ wgsl: "log", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.log(x), Math.log(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/log2.ts b/packages/ecs/src/interface/math/vec2/f32/log2.ts new file mode 100644 index 000000000..7b6baefcc --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/log2.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const log2 = intrinsic({ wgsl: "log2", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.log2(x), Math.log2(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/max.ts b/packages/ecs/src/interface/math/vec2/f32/max.ts new file mode 100644 index 000000000..784fcf996 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/max.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec2f", "vec2f"], result: "vec2f" }, ([x1, y1]: F32, [x2, y2]: F32): F32 => [Math.max(x1, x2), Math.max(y1, y2)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/min.ts b/packages/ecs/src/interface/math/vec2/f32/min.ts new file mode 100644 index 000000000..f94945e95 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/min.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec2f", "vec2f"], result: "vec2f" }, ([x1, y1]: F32, [x2, y2]: F32): F32 => [Math.min(x1, x2), Math.min(y1, y2)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/mix.ts b/packages/ecs/src/interface/math/vec2/f32/mix.ts new file mode 100644 index 000000000..b6667df20 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/mix.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const mix = intrinsic({ wgsl: "mix", parameters: ["vec2f", "vec2f", "f32"], result: "vec2f" }, ([x1, y1]: F32, [x2, y2]: F32, t: number): F32 => [ + x1 * (1 - t) + x2 * t, + y1 * (1 - t) + y2 * t +]); diff --git a/packages/ecs/src/interface/math/vec2/f32/mod.ts b/packages/ecs/src/interface/math/vec2/f32/mod.ts new file mode 100644 index 000000000..54946820d --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/mod.ts @@ -0,0 +1,3 @@ +import type { F32 } from "./f32.js"; + +export const mod = ([x, y]: F32, m: number): F32 => [x - m * Math.floor(x / m), y - m * Math.floor(y / m)]; diff --git a/packages/ecs/src/interface/math/vec2/f32/modf.ts b/packages/ecs/src/interface/math/vec2/f32/modf.ts new file mode 100644 index 000000000..dfad7eb03 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/modf.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { fract } from "./fract.js"; + +export const modf = ([x, y]: F32): { fract: F32; whole: F32 } => ({ + whole: [Math.trunc(x), Math.trunc(y)], + fract: [x - Math.trunc(x), y - Math.trunc(y)] +}); diff --git a/packages/ecs/src/interface/math/vec2/f32/negate.ts b/packages/ecs/src/interface/math/vec2/f32/negate.ts new file mode 100644 index 000000000..4b7799162 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/negate.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const negate = intrinsic({ wgsl: "-", form: "prefix", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [-x, -y]); diff --git a/packages/ecs/src/interface/math/vec2/f32/normalize.ts b/packages/ecs/src/interface/math/vec2/f32/normalize.ts new file mode 100644 index 000000000..013c30a42 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/normalize.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { length } from "./length.js"; +import { scale } from "./scale.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const normalize = intrinsic({ wgsl: "normalize", parameters: ["vec2f"], result: "vec2f" }, (v: F32): F32 => scale(v, 1 / length(v))); diff --git a/packages/ecs/src/interface/math/vec2/f32/pow.ts b/packages/ecs/src/interface/math/vec2/f32/pow.ts new file mode 100644 index 000000000..42c16593e --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/pow.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +/** Each lane of the first vector to the power of the second's; as WGSL's `pow`, indeterminate for a negative base, or a zero base with an exponent at most zero. */ +export const pow = intrinsic({ wgsl: "pow", parameters: ["vec2f", "vec2f"], result: "vec2f" }, ([x1, y1]: F32, [x2, y2]: F32): F32 => [ + Math.pow(x1, x2), + Math.pow(y1, y2) +]); diff --git a/packages/ecs/src/interface/math/vec2/f32/public.ts b/packages/ecs/src/interface/math/vec2/f32/public.ts new file mode 100644 index 000000000..5eaf58bd6 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/public.ts @@ -0,0 +1,47 @@ +export * from "./abs.js"; +export * from "./acos.js"; +export * from "./acosh.js"; +export * from "./add.js"; +export * from "./asin.js"; +export * from "./asinh.js"; +export * from "./atan.js"; +export * from "./atanh.js"; +export * from "./ceil.js"; +export * from "./clamp.js"; +export * from "./cos.js"; +export * from "./cosh.js"; +export * from "./distance.js"; +export * from "./dot.js"; +export * from "./equals.js"; +export * from "./exp.js"; +export * from "./exp2.js"; +export * from "./faceforward.js"; +export * from "./floor.js"; +export * from "./fract.js"; +export * from "./length.js"; +export * from "./log.js"; +export * from "./log2.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./mix.js"; +export * from "./mod.js"; +export * from "./modf.js"; +export * from "./negate.js"; +export * from "./normalize.js"; +export * from "./pow.js"; +export * from "./reflect.js"; +export * from "./refract.js"; +export * from "./round.js"; +export * from "./scale.js"; +export * from "./sign.js"; +export * from "./sin.js"; +export * from "./sinh.js"; +export * from "./smoothstep.js"; +export * from "./sqrt.js"; +export * from "./step.js"; +export * from "./subtract.js"; +export * from "./tan.js"; +export * from "./tanh.js"; +export * from "./trunc.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec2/f32/reflect.ts b/packages/ecs/src/interface/math/vec2/f32/reflect.ts new file mode 100644 index 000000000..c99f9c1a8 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/reflect.ts @@ -0,0 +1,10 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { scale } from "./scale.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reflect = intrinsic({ wgsl: "reflect", parameters: ["vec2f", "vec2f"], result: "vec2f" }, (i: F32, n: F32): F32 => { + const dot2 = dot(n, i) * 2; + return subtract(i, scale(n, dot2)); +}); diff --git a/packages/ecs/src/interface/math/vec2/f32/refract.ts b/packages/ecs/src/interface/math/vec2/f32/refract.ts new file mode 100644 index 000000000..3e3771bd8 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/refract.ts @@ -0,0 +1,15 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { scale } from "./scale.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const refract = intrinsic({ wgsl: "refract", parameters: ["vec2f", "vec2f", "f32"], result: "vec2f" }, (i: F32, n: F32, eta: number): F32 => { + const dotProduct = dot(n, i); + const k = 1.0 - eta * eta * (1.0 - dotProduct * dotProduct); + if (k < 0.0) { + return [0, 0]; + } + const scaleFactor = eta * dotProduct + Math.sqrt(k); + return subtract(scale(i, eta), scale(n, scaleFactor)); +}); diff --git a/packages/ecs/src/interface/math/vec2/f32/round.ts b/packages/ecs/src/interface/math/vec2/f32/round.ts new file mode 100644 index 000000000..c4c19158a --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/round.ts @@ -0,0 +1,5 @@ +import type { F32 } from "./f32.js"; +import { roundHalfEven } from "../../f32/round-half-even.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const round = intrinsic({ wgsl: "round", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [roundHalfEven(x), roundHalfEven(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/scale.ts b/packages/ecs/src/interface/math/vec2/f32/scale.ts new file mode 100644 index 000000000..387fc5537 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/scale.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec2f", "f32"], result: "vec2f" }, ([x, y]: F32, s: number): F32 => [x * s, y * s]); diff --git a/packages/ecs/src/interface/math/vec2/f32/schema.ts b/packages/ecs/src/interface/math/vec2/f32/schema.ts new file mode 100644 index 000000000..d9daac9fc --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../f32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 2, maxItems: 2 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec2/f32/sign.ts b/packages/ecs/src/interface/math/vec2/f32/sign.ts new file mode 100644 index 000000000..e279cdd71 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/sign.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sign = intrinsic({ wgsl: "sign", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.sign(x), Math.sign(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/sin.ts b/packages/ecs/src/interface/math/vec2/f32/sin.ts new file mode 100644 index 000000000..7873f722d --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/sin.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sin = intrinsic({ wgsl: "sin", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.sin(x), Math.sin(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/sinh.ts b/packages/ecs/src/interface/math/vec2/f32/sinh.ts new file mode 100644 index 000000000..3bb034d70 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/sinh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sinh = intrinsic({ wgsl: "sinh", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.sinh(x), Math.sinh(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/smoothstep.ts b/packages/ecs/src/interface/math/vec2/f32/smoothstep.ts new file mode 100644 index 000000000..bd351328f --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/smoothstep.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const smoothstep = intrinsic({ wgsl: "smoothstep", parameters: ["vec2f", "vec2f", "vec2f"], result: "vec2f" }, ([e0x, e0y]: F32, [e1x, e1y]: F32, [x, y]: F32): F32 => { + const tx = Math.max(0, Math.min(1, (x - e0x) / (e1x - e0x))); + const ty = Math.max(0, Math.min(1, (y - e0y) / (e1y - e0y))); + return [tx * tx * (3 - 2 * tx), ty * ty * (3 - 2 * ty)]; +}); diff --git a/packages/ecs/src/interface/math/vec2/f32/sqrt.ts b/packages/ecs/src/interface/math/vec2/f32/sqrt.ts new file mode 100644 index 000000000..02fb4f4eb --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/sqrt.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sqrt = intrinsic({ wgsl: "sqrt", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.sqrt(x), Math.sqrt(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/step.ts b/packages/ecs/src/interface/math/vec2/f32/step.ts new file mode 100644 index 000000000..4e468740f --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/step.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const step = intrinsic({ wgsl: "step", parameters: ["vec2f", "vec2f"], result: "vec2f" }, ([edge1, edge2]: F32, [x, y]: F32): F32 => [ + x < edge1 ? 0 : 1, + y < edge2 ? 0 : 1 +]); diff --git a/packages/ecs/src/interface/math/vec2/f32/subtract.ts b/packages/ecs/src/interface/math/vec2/f32/subtract.ts new file mode 100644 index 000000000..b38cdcfb1 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/subtract.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec2f", "vec2f"], result: "vec2f" }, ([x1, y1]: F32, [x2, y2]: F32): F32 => [x1 - x2, y1 - y2]); diff --git a/packages/ecs/src/interface/math/vec2/f32/tan.ts b/packages/ecs/src/interface/math/vec2/f32/tan.ts new file mode 100644 index 000000000..85a139c29 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/tan.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const tan = intrinsic({ wgsl: "tan", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.tan(x), Math.tan(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/tanh.ts b/packages/ecs/src/interface/math/vec2/f32/tanh.ts new file mode 100644 index 000000000..e017c5fa9 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/tanh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const tanh = intrinsic({ wgsl: "tanh", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.tanh(x), Math.tanh(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/trunc.ts b/packages/ecs/src/interface/math/vec2/f32/trunc.ts new file mode 100644 index 000000000..efd388a56 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/trunc.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const trunc = intrinsic({ wgsl: "trunc", parameters: ["vec2f"], result: "vec2f" }, ([x, y]: F32): F32 => [Math.trunc(x), Math.trunc(y)]); diff --git a/packages/ecs/src/interface/math/vec2/f32/zero.ts b/packages/ecs/src/interface/math/vec2/f32/zero.ts new file mode 100644 index 000000000..48eacb574 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/f32/zero.ts @@ -0,0 +1,3 @@ +import type { F32 } from "./f32.js"; + +export const zero: F32 = [0, 0]; diff --git a/packages/ecs/src/interface/math/vec2/i32/abs.ts b/packages/ecs/src/interface/math/vec2/i32/abs.ts new file mode 100644 index 000000000..e0ae9bd02 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/abs.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const abs = intrinsic({ wgsl: "abs", parameters: ["vec2i"], result: "vec2i" }, ([x, y]: Vec2_I32): Vec2_I32 => [Math.abs(x) | 0, Math.abs(y) | 0]); diff --git a/packages/ecs/src/interface/math/vec2/i32/add.ts b/packages/ecs/src/interface/math/vec2/i32/add.ts new file mode 100644 index 000000000..5a1c95da4 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/add.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec2i", "vec2i"], result: "vec2i" }, ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): Vec2_I32 => [(x1 + x2) | 0, (y1 + y2) | 0]); diff --git a/packages/ecs/src/interface/math/vec2/i32/bitwise-and.ts b/packages/ecs/src/interface/math/vec2/i32/bitwise-and.ts new file mode 100644 index 000000000..c2ad98b62 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/bitwise-and.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseAnd = intrinsic({ wgsl: "&", form: "infix", parameters: ["vec2i", "vec2i"], result: "vec2i" }, ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): Vec2_I32 => [x1 & x2, y1 & y2]); diff --git a/packages/ecs/src/interface/math/vec2/i32/bitwise-not.ts b/packages/ecs/src/interface/math/vec2/i32/bitwise-not.ts new file mode 100644 index 000000000..ab0cef53c --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/bitwise-not.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseNot = intrinsic({ wgsl: "~", form: "prefix", parameters: ["vec2i"], result: "vec2i" }, ([x, y]: Vec2_I32): Vec2_I32 => [~x, ~y]); diff --git a/packages/ecs/src/interface/math/vec2/i32/bitwise-or.ts b/packages/ecs/src/interface/math/vec2/i32/bitwise-or.ts new file mode 100644 index 000000000..77e67215c --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/bitwise-or.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseOr = intrinsic({ wgsl: "|", form: "infix", parameters: ["vec2i", "vec2i"], result: "vec2i" }, ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): Vec2_I32 => [x1 | x2, y1 | y2]); diff --git a/packages/ecs/src/interface/math/vec2/i32/bitwise-xor.ts b/packages/ecs/src/interface/math/vec2/i32/bitwise-xor.ts new file mode 100644 index 000000000..71b092fd3 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/bitwise-xor.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseXor = intrinsic({ wgsl: "^", form: "infix", parameters: ["vec2i", "vec2i"], result: "vec2i" }, ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): Vec2_I32 => [x1 ^ x2, y1 ^ y2]); diff --git a/packages/ecs/src/interface/math/vec2/i32/clamp.ts b/packages/ecs/src/interface/math/vec2/i32/clamp.ts new file mode 100644 index 000000000..f80751f76 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/clamp.ts @@ -0,0 +1,6 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec2i", "vec2i", "vec2i"], result: "vec2i" }, (v: Vec2_I32, minVec: Vec2_I32, maxVec: Vec2_I32): Vec2_I32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec2/i32/count-leading-zeros.ts b/packages/ecs/src/interface/math/vec2/i32/count-leading-zeros.ts new file mode 100644 index 000000000..fa427039c --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/count-leading-zeros.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countLeadingZeros = intrinsic({ wgsl: "countLeadingZeros", parameters: ["vec2i"], result: "vec2i" }, ([x, y]: Vec2_I32): Vec2_I32 => [ + U32.countLeadingZeros(x), + U32.countLeadingZeros(y), +]); diff --git a/packages/ecs/src/interface/math/vec2/i32/count-one-bits.ts b/packages/ecs/src/interface/math/vec2/i32/count-one-bits.ts new file mode 100644 index 000000000..17943eabb --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/count-one-bits.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countOneBits = intrinsic({ wgsl: "countOneBits", parameters: ["vec2i"], result: "vec2i" }, ([x, y]: Vec2_I32): Vec2_I32 => [ + U32.countOneBits(x), + U32.countOneBits(y), +]); diff --git a/packages/ecs/src/interface/math/vec2/i32/count-trailing-zeros.ts b/packages/ecs/src/interface/math/vec2/i32/count-trailing-zeros.ts new file mode 100644 index 000000000..b0f100a61 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/count-trailing-zeros.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countTrailingZeros = intrinsic({ wgsl: "countTrailingZeros", parameters: ["vec2i"], result: "vec2i" }, ([x, y]: Vec2_I32): Vec2_I32 => [ + U32.countTrailingZeros(x), + U32.countTrailingZeros(y), +]); diff --git a/packages/ecs/src/interface/math/vec2/i32/equals.ts b/packages/ecs/src/interface/math/vec2/i32/equals.ts new file mode 100644 index 000000000..4843440ec --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/equals.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; + +export const equals = ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): boolean => x1 === x2 && y1 === y2; diff --git a/packages/ecs/src/interface/math/vec2/i32/i32.ts b/packages/ecs/src/interface/math/vec2/i32/i32.ts new file mode 100644 index 000000000..f37f1528b --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/i32.ts @@ -0,0 +1,4 @@ +/** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ +export type I32 = readonly [number, number]; + +export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/i32/max.ts b/packages/ecs/src/interface/math/vec2/i32/max.ts new file mode 100644 index 000000000..97896b094 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/max.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec2i", "vec2i"], result: "vec2i" }, ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): Vec2_I32 => [Math.max(x1, x2), Math.max(y1, y2)]); diff --git a/packages/ecs/src/interface/math/vec2/i32/min.ts b/packages/ecs/src/interface/math/vec2/i32/min.ts new file mode 100644 index 000000000..8cdb8804e --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/min.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec2i", "vec2i"], result: "vec2i" }, ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): Vec2_I32 => [Math.min(x1, x2), Math.min(y1, y2)]); diff --git a/packages/ecs/src/interface/math/vec2/i32/negate.ts b/packages/ecs/src/interface/math/vec2/i32/negate.ts new file mode 100644 index 000000000..e138374c7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/negate.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const negate = intrinsic({ wgsl: "-", form: "prefix", parameters: ["vec2i"], result: "vec2i" }, ([x, y]: Vec2_I32): Vec2_I32 => [(-x) | 0, (-y) | 0]); diff --git a/packages/ecs/src/interface/math/vec2/i32/public.ts b/packages/ecs/src/interface/math/vec2/i32/public.ts new file mode 100644 index 000000000..b66dde3a2 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/public.ts @@ -0,0 +1,21 @@ +export * from "./abs.js"; +export * from "./add.js"; +export * from "./bitwise-and.js"; +export * from "./bitwise-not.js"; +export * from "./bitwise-or.js"; +export * from "./bitwise-xor.js"; +export * from "./clamp.js"; +export * from "./count-leading-zeros.js"; +export * from "./count-one-bits.js"; +export * from "./count-trailing-zeros.js"; +export * from "./equals.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./negate.js"; +export * from "./reverse-bits.js"; +export * from "./scale.js"; +export * from "./shift-left.js"; +export * from "./shift-right.js"; +export * from "./subtract.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec2/i32/reverse-bits.ts b/packages/ecs/src/interface/math/vec2/i32/reverse-bits.ts new file mode 100644 index 000000000..0ca886ad7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/reverse-bits.ts @@ -0,0 +1,5 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reverseBits = intrinsic({ wgsl: "reverseBits", parameters: ["vec2i"], result: "vec2i" }, ([x, y]: Vec2_I32): Vec2_I32 => [U32.reverseBits(x) | 0, U32.reverseBits(y) | 0]); diff --git a/packages/ecs/src/interface/math/vec2/i32/scale.ts b/packages/ecs/src/interface/math/vec2/i32/scale.ts new file mode 100644 index 000000000..73dae8c99 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/scale.ts @@ -0,0 +1,5 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; +import type { I32 } from "../../i32/index.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec2i", "i32"], result: "vec2i" }, ([x, y]: Vec2_I32, s: I32): Vec2_I32 => [Math.imul(x, s), Math.imul(y, s)]); diff --git a/packages/ecs/src/interface/math/vec2/i32/schema.ts b/packages/ecs/src/interface/math/vec2/i32/schema.ts new file mode 100644 index 000000000..d34a0a114 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../i32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 2, maxItems: 2 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec2/i32/shift-left.ts b/packages/ecs/src/interface/math/vec2/i32/shift-left.ts new file mode 100644 index 000000000..cae352e47 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/shift-left.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; + +export const shiftLeft = ([x, y]: Vec2_I32, s: number): Vec2_I32 => [x << s, y << s]; diff --git a/packages/ecs/src/interface/math/vec2/i32/shift-right.ts b/packages/ecs/src/interface/math/vec2/i32/shift-right.ts new file mode 100644 index 000000000..f86371128 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/shift-right.ts @@ -0,0 +1,7 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { I32 } from "../../i32/index.js"; + +export const shiftRight = ([x, y]: Vec2_I32, s: number): Vec2_I32 => [ + I32.shiftRight(x, s), + I32.shiftRight(y, s), +]; diff --git a/packages/ecs/src/interface/math/vec2/i32/subtract.ts b/packages/ecs/src/interface/math/vec2/i32/subtract.ts new file mode 100644 index 000000000..e01134668 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/subtract.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec2i", "vec2i"], result: "vec2i" }, ([x1, y1]: Vec2_I32, [x2, y2]: Vec2_I32): Vec2_I32 => [(x1 - x2) | 0, (y1 - y2) | 0]); diff --git a/packages/ecs/src/interface/math/vec2/i32/zero.ts b/packages/ecs/src/interface/math/vec2/i32/zero.ts new file mode 100644 index 000000000..77dbfa44e --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/i32/zero.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec2_I32 } from "./i32.js"; + +export const zero: Vec2_I32 = [0, 0]; diff --git a/packages/ecs/src/interface/math/vec2/index.ts b/packages/ecs/src/interface/math/vec2/index.ts new file mode 100644 index 000000000..ffaacde7f --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/index.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32/f32.js"; + +/** Vec2 is `Vec2.F32`; `Vec2.I32` and `Vec2.U32` hold integer vectors. */ +export type Vec2 = F32; + +export * as Vec2 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/public.ts b/packages/ecs/src/interface/math/vec2/public.ts new file mode 100644 index 000000000..c701852dd --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/public.ts @@ -0,0 +1,4 @@ +export { F32 } from "./f32/f32.js"; +export { I32 } from "./i32/i32.js"; +export { U32 } from "./u32/u32.js"; +export * from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec2/u32/add.ts b/packages/ecs/src/interface/math/vec2/u32/add.ts new file mode 100644 index 000000000..4b81e994e --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/add.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec2u", "vec2u"], result: "vec2u" }, ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): Vec2_U32 => [ + (x1 + x2) >>> 0, + (y1 + y2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/bitwise-and.ts b/packages/ecs/src/interface/math/vec2/u32/bitwise-and.ts new file mode 100644 index 000000000..f99cd8b5b --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/bitwise-and.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseAnd = intrinsic({ wgsl: "&", form: "infix", parameters: ["vec2u", "vec2u"], result: "vec2u" }, ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): Vec2_U32 => [ + (x1 & x2) >>> 0, + (y1 & y2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/bitwise-not.ts b/packages/ecs/src/interface/math/vec2/u32/bitwise-not.ts new file mode 100644 index 000000000..c8681b85a --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/bitwise-not.ts @@ -0,0 +1,4 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseNot = intrinsic({ wgsl: "~", form: "prefix", parameters: ["vec2u"], result: "vec2u" }, ([x, y]: Vec2_U32): Vec2_U32 => [(~x) >>> 0, (~y) >>> 0]); diff --git a/packages/ecs/src/interface/math/vec2/u32/bitwise-or.ts b/packages/ecs/src/interface/math/vec2/u32/bitwise-or.ts new file mode 100644 index 000000000..19cc5449d --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/bitwise-or.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseOr = intrinsic({ wgsl: "|", form: "infix", parameters: ["vec2u", "vec2u"], result: "vec2u" }, ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): Vec2_U32 => [ + (x1 | x2) >>> 0, + (y1 | y2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/bitwise-xor.ts b/packages/ecs/src/interface/math/vec2/u32/bitwise-xor.ts new file mode 100644 index 000000000..e97aaf70a --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/bitwise-xor.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseXor = intrinsic({ wgsl: "^", form: "infix", parameters: ["vec2u", "vec2u"], result: "vec2u" }, ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): Vec2_U32 => [ + (x1 ^ x2) >>> 0, + (y1 ^ y2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/clamp.ts b/packages/ecs/src/interface/math/vec2/u32/clamp.ts new file mode 100644 index 000000000..6ed9774db --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/clamp.ts @@ -0,0 +1,6 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec2u", "vec2u", "vec2u"], result: "vec2u" }, (v: Vec2_U32, minVec: Vec2_U32, maxVec: Vec2_U32): Vec2_U32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec2/u32/count-leading-zeros.ts b/packages/ecs/src/interface/math/vec2/u32/count-leading-zeros.ts new file mode 100644 index 000000000..21cda6eae --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/count-leading-zeros.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countLeadingZeros = intrinsic({ wgsl: "countLeadingZeros", parameters: ["vec2u"], result: "vec2u" }, ([x, y]: Vec2_U32): Vec2_U32 => [ + U32.countLeadingZeros(x), + U32.countLeadingZeros(y), +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/count-one-bits.ts b/packages/ecs/src/interface/math/vec2/u32/count-one-bits.ts new file mode 100644 index 000000000..272b1760f --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/count-one-bits.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countOneBits = intrinsic({ wgsl: "countOneBits", parameters: ["vec2u"], result: "vec2u" }, ([x, y]: Vec2_U32): Vec2_U32 => [ + U32.countOneBits(x), + U32.countOneBits(y), +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/count-trailing-zeros.ts b/packages/ecs/src/interface/math/vec2/u32/count-trailing-zeros.ts new file mode 100644 index 000000000..aaba8e123 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/count-trailing-zeros.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countTrailingZeros = intrinsic({ wgsl: "countTrailingZeros", parameters: ["vec2u"], result: "vec2u" }, ([x, y]: Vec2_U32): Vec2_U32 => [ + U32.countTrailingZeros(x), + U32.countTrailingZeros(y), +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/equals.ts b/packages/ecs/src/interface/math/vec2/u32/equals.ts new file mode 100644 index 000000000..eadbdb6e7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/equals.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; + +export const equals = ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): boolean => x1 === x2 && y1 === y2; diff --git a/packages/ecs/src/interface/math/vec2/u32/max.ts b/packages/ecs/src/interface/math/vec2/u32/max.ts new file mode 100644 index 000000000..e6b3d0a60 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/max.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec2u", "vec2u"], result: "vec2u" }, ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): Vec2_U32 => [ + Math.max(x1, x2) >>> 0, + Math.max(y1, y2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/min.ts b/packages/ecs/src/interface/math/vec2/u32/min.ts new file mode 100644 index 000000000..adabccf3d --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/min.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec2u", "vec2u"], result: "vec2u" }, ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): Vec2_U32 => [ + Math.min(x1, x2) >>> 0, + Math.min(y1, y2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/negate.ts b/packages/ecs/src/interface/math/vec2/u32/negate.ts new file mode 100644 index 000000000..b1455c671 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/negate.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; + +export const negate = ([x, y]: Vec2_U32): Vec2_U32 => [(-x) >>> 0, (-y) >>> 0]; diff --git a/packages/ecs/src/interface/math/vec2/u32/public.ts b/packages/ecs/src/interface/math/vec2/u32/public.ts new file mode 100644 index 000000000..905b86f32 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/public.ts @@ -0,0 +1,20 @@ +export * from "./add.js"; +export * from "./bitwise-and.js"; +export * from "./bitwise-not.js"; +export * from "./bitwise-or.js"; +export * from "./bitwise-xor.js"; +export * from "./clamp.js"; +export * from "./count-leading-zeros.js"; +export * from "./count-one-bits.js"; +export * from "./count-trailing-zeros.js"; +export * from "./equals.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./negate.js"; +export * from "./reverse-bits.js"; +export * from "./scale.js"; +export * from "./shift-left.js"; +export * from "./shift-right.js"; +export * from "./subtract.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec2/u32/reverse-bits.ts b/packages/ecs/src/interface/math/vec2/u32/reverse-bits.ts new file mode 100644 index 000000000..334f995f7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/reverse-bits.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reverseBits = intrinsic({ wgsl: "reverseBits", parameters: ["vec2u"], result: "vec2u" }, ([x, y]: Vec2_U32): Vec2_U32 => [ + U32.reverseBits(x), + U32.reverseBits(y), +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/scale.ts b/packages/ecs/src/interface/math/vec2/u32/scale.ts new file mode 100644 index 000000000..d8ff8cec5 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/scale.ts @@ -0,0 +1,5 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; +import type { U32 } from "../../u32/index.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec2u", "u32"], result: "vec2u" }, ([x, y]: Vec2_U32, s: U32): Vec2_U32 => [Math.imul(x, s) >>> 0, Math.imul(y, s) >>> 0]); diff --git a/packages/ecs/src/interface/math/vec2/u32/schema.ts b/packages/ecs/src/interface/math/vec2/u32/schema.ts new file mode 100644 index 000000000..122fc3c1b --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../u32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 2, maxItems: 2 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec2/u32/shift-left.ts b/packages/ecs/src/interface/math/vec2/u32/shift-left.ts new file mode 100644 index 000000000..81ede8430 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/shift-left.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; + +export const shiftLeft = ([x, y]: Vec2_U32, s: number): Vec2_U32 => [(x << s) >>> 0, (y << s) >>> 0]; diff --git a/packages/ecs/src/interface/math/vec2/u32/shift-right.ts b/packages/ecs/src/interface/math/vec2/u32/shift-right.ts new file mode 100644 index 000000000..ff2cf2402 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/shift-right.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; + +export const shiftRight = ([x, y]: Vec2_U32, s: number): Vec2_U32 => [ + U32.shiftRight(x, s), + U32.shiftRight(y, s), +]; diff --git a/packages/ecs/src/interface/math/vec2/u32/subtract.ts b/packages/ecs/src/interface/math/vec2/u32/subtract.ts new file mode 100644 index 000000000..1c785e62f --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/subtract.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec2u", "vec2u"], result: "vec2u" }, ([x1, y1]: Vec2_U32, [x2, y2]: Vec2_U32): Vec2_U32 => [ + (x1 - x2) >>> 0, + (y1 - y2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec2/u32/u32.ts b/packages/ecs/src/interface/math/vec2/u32/u32.ts new file mode 100644 index 000000000..299dfb25e --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/u32.ts @@ -0,0 +1,4 @@ +/** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ +export type U32 = readonly [number, number]; + +export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/u32/zero.ts b/packages/ecs/src/interface/math/vec2/u32/zero.ts new file mode 100644 index 000000000..459e60ef7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec2/u32/zero.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec2_U32 } from "./u32.js"; + +export const zero: Vec2_U32 = [0, 0]; diff --git a/packages/ecs/src/interface/math/vec3/f32/abs.ts b/packages/ecs/src/interface/math/vec3/f32/abs.ts new file mode 100644 index 000000000..19e38e77e --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/abs.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const abs = intrinsic({ wgsl: "abs", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.abs(x), Math.abs(y), Math.abs(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/acos.ts b/packages/ecs/src/interface/math/vec3/f32/acos.ts new file mode 100644 index 000000000..b97171978 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/acos.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const acos = intrinsic({ wgsl: "acos", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.acos(x), Math.acos(y), Math.acos(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/acosh.ts b/packages/ecs/src/interface/math/vec3/f32/acosh.ts new file mode 100644 index 000000000..dfca425c6 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/acosh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const acosh = intrinsic({ wgsl: "acosh", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.acosh(x), Math.acosh(y), Math.acosh(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/add.ts b/packages/ecs/src/interface/math/vec3/f32/add.ts new file mode 100644 index 000000000..7f4514a0a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/add.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): F32 => [ + x1 + x2, + y1 + y2, + z1 + z2 +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/asin.ts b/packages/ecs/src/interface/math/vec3/f32/asin.ts new file mode 100644 index 000000000..735dbea1c --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/asin.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const asin = intrinsic({ wgsl: "asin", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.asin(x), Math.asin(y), Math.asin(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/asinh.ts b/packages/ecs/src/interface/math/vec3/f32/asinh.ts new file mode 100644 index 000000000..85171d894 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/asinh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const asinh = intrinsic({ wgsl: "asinh", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.asinh(x), Math.asinh(y), Math.asinh(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/atan.ts b/packages/ecs/src/interface/math/vec3/f32/atan.ts new file mode 100644 index 000000000..122d4af70 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/atan.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const atan = intrinsic({ wgsl: "atan", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.atan(x), Math.atan(y), Math.atan(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/atanh.ts b/packages/ecs/src/interface/math/vec3/f32/atanh.ts new file mode 100644 index 000000000..12cdb17f0 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/atanh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const atanh = intrinsic({ wgsl: "atanh", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.atanh(x), Math.atanh(y), Math.atanh(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/ceil.ts b/packages/ecs/src/interface/math/vec3/f32/ceil.ts new file mode 100644 index 000000000..a8b513963 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/ceil.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const ceil = intrinsic({ wgsl: "ceil", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.ceil(x), Math.ceil(y), Math.ceil(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/clamp.ts b/packages/ecs/src/interface/math/vec3/f32/clamp.ts new file mode 100644 index 000000000..94cd2250e --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/clamp.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec3f", "vec3f", "vec3f"], result: "vec3f" }, (v: F32, minVec: F32, maxVec: F32): F32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec3/f32/cos.ts b/packages/ecs/src/interface/math/vec3/f32/cos.ts new file mode 100644 index 000000000..14bb6d2cc --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/cos.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const cos = intrinsic({ wgsl: "cos", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.cos(x), Math.cos(y), Math.cos(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/cosh.ts b/packages/ecs/src/interface/math/vec3/f32/cosh.ts new file mode 100644 index 000000000..d208334a7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/cosh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const cosh = intrinsic({ wgsl: "cosh", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.cosh(x), Math.cosh(y), Math.cosh(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/cross.ts b/packages/ecs/src/interface/math/vec3/f32/cross.ts new file mode 100644 index 000000000..d2dfe959c --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/cross.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const cross = intrinsic({ wgsl: "cross", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): F32 => [ + y1 * z2 - z1 * y2, + z1 * x2 - x1 * z2, + x1 * y2 - y1 * x2 +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/distance-squared.ts b/packages/ecs/src/interface/math/vec3/f32/distance-squared.ts new file mode 100644 index 000000000..396f857b4 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/distance-squared.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; + +export const distanceSquared = (a: F32, b: F32): number => { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const dz = b[2] - a[2]; + return dx * dx + dy * dy + dz * dz; +}; diff --git a/packages/ecs/src/interface/math/vec3/f32/distance.ts b/packages/ecs/src/interface/math/vec3/f32/distance.ts new file mode 100644 index 000000000..67e5745bb --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/distance.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { length } from "./length.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const distance = intrinsic({ wgsl: "distance", parameters: ["vec3f", "vec3f"], result: "f32" }, (a: F32, b: F32): number => length(subtract(b, a))); diff --git a/packages/ecs/src/interface/math/vec3/f32/dot.ts b/packages/ecs/src/interface/math/vec3/f32/dot.ts new file mode 100644 index 000000000..2e1b3666d --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/dot.ts @@ -0,0 +1,5 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const dot = intrinsic({ wgsl: "dot", parameters: ["vec3f", "vec3f"], result: "f32" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): number => + x1 * x2 + y1 * y2 + z1 * z2); diff --git a/packages/ecs/src/interface/math/vec3/f32/equals.ts b/packages/ecs/src/interface/math/vec3/f32/equals.ts new file mode 100644 index 000000000..90423bf42 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/equals.ts @@ -0,0 +1,3 @@ +import type { F32 } from "./f32.js"; + +export const equals = (a: F32, b: F32): boolean => a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; diff --git a/packages/ecs/src/interface/math/vec3/f32/exp.ts b/packages/ecs/src/interface/math/vec3/f32/exp.ts new file mode 100644 index 000000000..47c5a618f --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/exp.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const exp = intrinsic({ wgsl: "exp", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.exp(x), Math.exp(y), Math.exp(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/exp2.ts b/packages/ecs/src/interface/math/vec3/f32/exp2.ts new file mode 100644 index 000000000..5a0ae3a03 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/exp2.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const exp2 = intrinsic({ wgsl: "exp2", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.pow(2, x), Math.pow(2, y), Math.pow(2, z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/f32.ts b/packages/ecs/src/interface/math/vec3/f32/f32.ts new file mode 100644 index 000000000..228586f50 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/f32.ts @@ -0,0 +1,3 @@ +export type F32 = readonly [number, number, number]; + +export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/f32/faceforward.ts b/packages/ecs/src/interface/math/vec3/f32/faceforward.ts new file mode 100644 index 000000000..995ba6cad --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/faceforward.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { negate } from "./negate.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const faceforward = intrinsic({ wgsl: "faceForward", parameters: ["vec3f", "vec3f", "vec3f"], result: "vec3f" }, (n: F32, i: F32, nref: F32): F32 => + dot(nref, i) < 0 ? n : negate(n)); diff --git a/packages/ecs/src/interface/math/vec3/f32/floor.ts b/packages/ecs/src/interface/math/vec3/f32/floor.ts new file mode 100644 index 000000000..fc9680c98 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/floor.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const floor = intrinsic({ wgsl: "floor", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.floor(x), Math.floor(y), Math.floor(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/fract.ts b/packages/ecs/src/interface/math/vec3/f32/fract.ts new file mode 100644 index 000000000..40ea05e11 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/fract.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const fract = intrinsic({ wgsl: "fract", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [ + x - Math.floor(x), + y - Math.floor(y), + z - Math.floor(z) +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/length-squared.ts b/packages/ecs/src/interface/math/vec3/f32/length-squared.ts new file mode 100644 index 000000000..8a5609c4f --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/length-squared.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; + +export const lengthSquared = (v: F32): number => dot(v, v); diff --git a/packages/ecs/src/interface/math/vec3/f32/length.ts b/packages/ecs/src/interface/math/vec3/f32/length.ts new file mode 100644 index 000000000..ab6ac7795 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/length.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const length = intrinsic({ wgsl: "length", parameters: ["vec3f"], result: "f32" }, ([x, y, z]: F32): number => Math.sqrt(x * x + y * y + z * z)); diff --git a/packages/ecs/src/interface/math/vec3/f32/log.ts b/packages/ecs/src/interface/math/vec3/f32/log.ts new file mode 100644 index 000000000..8ee921d8d --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/log.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const log = intrinsic({ wgsl: "log", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.log(x), Math.log(y), Math.log(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/log2.ts b/packages/ecs/src/interface/math/vec3/f32/log2.ts new file mode 100644 index 000000000..748190cc0 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/log2.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const log2 = intrinsic({ wgsl: "log2", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.log2(x), Math.log2(y), Math.log2(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/max.ts b/packages/ecs/src/interface/math/vec3/f32/max.ts new file mode 100644 index 000000000..87c015d9d --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/max.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): F32 => [ + Math.max(x1, x2), + Math.max(y1, y2), + Math.max(z1, z2) +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/min.ts b/packages/ecs/src/interface/math/vec3/f32/min.ts new file mode 100644 index 000000000..872d49d7a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/min.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): F32 => [ + Math.min(x1, x2), + Math.min(y1, y2), + Math.min(z1, z2) +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/mix.ts b/packages/ecs/src/interface/math/vec3/f32/mix.ts new file mode 100644 index 000000000..740e6b66d --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/mix.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const mix = intrinsic({ wgsl: "mix", parameters: ["vec3f", "vec3f", "f32"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32, t: number): F32 => [ + x1 * (1 - t) + x2 * t, + y1 * (1 - t) + y2 * t, + z1 * (1 - t) + z2 * t +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/mod.ts b/packages/ecs/src/interface/math/vec3/f32/mod.ts new file mode 100644 index 000000000..a7973d657 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/mod.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; + +export const mod = ([x, y, z]: F32, m: number): F32 => [ + x - m * Math.floor(x / m), + y - m * Math.floor(y / m), + z - m * Math.floor(z / m), +]; diff --git a/packages/ecs/src/interface/math/vec3/f32/modf.ts b/packages/ecs/src/interface/math/vec3/f32/modf.ts new file mode 100644 index 000000000..5919c5fed --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/modf.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { fract } from "./fract.js"; + +export const modf = ([x, y, z]: F32): { fract: F32; whole: F32 } => ({ + whole: [Math.trunc(x), Math.trunc(y), Math.trunc(z)], + fract: [x - Math.trunc(x), y - Math.trunc(y), z - Math.trunc(z)] +}); diff --git a/packages/ecs/src/interface/math/vec3/f32/multiply.ts b/packages/ecs/src/interface/math/vec3/f32/multiply.ts new file mode 100644 index 000000000..2b24a2646 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/multiply.ts @@ -0,0 +1,11 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +/** + * Performs a component-wise multiplication of two vectors. + */ +export const multiply = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): F32 => [ + x1 * x2, + y1 * y2, + z1 * z2 +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/negate.ts b/packages/ecs/src/interface/math/vec3/f32/negate.ts new file mode 100644 index 000000000..65c6c984a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/negate.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const negate = intrinsic({ wgsl: "-", form: "prefix", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [-x, -y, -z]); diff --git a/packages/ecs/src/interface/math/vec3/f32/normalize.ts b/packages/ecs/src/interface/math/vec3/f32/normalize.ts new file mode 100644 index 000000000..ead3d1933 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/normalize.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { length } from "./length.js"; +import { scale } from "./scale.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const normalize = intrinsic({ wgsl: "normalize", parameters: ["vec3f"], result: "vec3f" }, (v: F32): F32 => scale(v, 1 / length(v))); diff --git a/packages/ecs/src/interface/math/vec3/f32/one.ts b/packages/ecs/src/interface/math/vec3/f32/one.ts new file mode 100644 index 000000000..6c603c23a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/one.ts @@ -0,0 +1,3 @@ +import type { F32 } from "./f32.js"; + +export const one: F32 = [1, 1, 1]; diff --git a/packages/ecs/src/interface/math/vec3/f32/pow.ts b/packages/ecs/src/interface/math/vec3/f32/pow.ts new file mode 100644 index 000000000..23ec43bec --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/pow.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +/** Each lane of the first vector to the power of the second's; as WGSL's `pow`, indeterminate for a negative base, or a zero base with an exponent at most zero. */ +export const pow = intrinsic({ wgsl: "pow", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): F32 => [ + Math.pow(x1, x2), + Math.pow(y1, y2), + Math.pow(z1, z2) +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/public.ts b/packages/ecs/src/interface/math/vec3/f32/public.ts new file mode 100644 index 000000000..8852ed6df --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/public.ts @@ -0,0 +1,53 @@ +export * from "./abs.js"; +export * from "./acos.js"; +export * from "./acosh.js"; +export * from "./add.js"; +export * from "./asin.js"; +export * from "./asinh.js"; +export * from "./atan.js"; +export * from "./atanh.js"; +export * from "./ceil.js"; +export * from "./clamp.js"; +export * from "./cos.js"; +export * from "./cosh.js"; +export * from "./cross.js"; +export * from "./distance-squared.js"; +export * from "./distance.js"; +export * from "./dot.js"; +export * from "./equals.js"; +export * from "./exp.js"; +export * from "./exp2.js"; +export * from "./faceforward.js"; +export * from "./floor.js"; +export * from "./fract.js"; +export * from "./length-squared.js"; +export * from "./length.js"; +export * from "./log.js"; +export * from "./log2.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./mix.js"; +export * from "./mod.js"; +export * from "./modf.js"; +export * from "./multiply.js"; +export * from "./negate.js"; +export * from "./normalize.js"; +export * from "./one.js"; +export * from "./pow.js"; +export * from "./random.js"; +export * from "./reflect.js"; +export * from "./refract.js"; +export * from "./round.js"; +export * from "./scale.js"; +export * from "./sign.js"; +export * from "./sin.js"; +export * from "./sinh.js"; +export * from "./smoothstep.js"; +export * from "./sqrt.js"; +export * from "./step.js"; +export * from "./subtract.js"; +export * from "./tan.js"; +export * from "./tanh.js"; +export * from "./trunc.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec3/f32/random.ts b/packages/ecs/src/interface/math/vec3/f32/random.ts new file mode 100644 index 000000000..4dc35f0c2 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/random.ts @@ -0,0 +1,5 @@ +import type { F32 } from "./f32.js"; + +export const random = (): F32 => { + return [Math.random(), Math.random(), Math.random()]; +}; diff --git a/packages/ecs/src/interface/math/vec3/f32/reflect.ts b/packages/ecs/src/interface/math/vec3/f32/reflect.ts new file mode 100644 index 000000000..860200276 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/reflect.ts @@ -0,0 +1,10 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { scale } from "./scale.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reflect = intrinsic({ wgsl: "reflect", parameters: ["vec3f", "vec3f"], result: "vec3f" }, (i: F32, n: F32): F32 => { + const dot2 = dot(n, i) * 2; + return subtract(i, scale(n, dot2)); +}); diff --git a/packages/ecs/src/interface/math/vec3/f32/refract.ts b/packages/ecs/src/interface/math/vec3/f32/refract.ts new file mode 100644 index 000000000..cd4170526 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/refract.ts @@ -0,0 +1,15 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { scale } from "./scale.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const refract = intrinsic({ wgsl: "refract", parameters: ["vec3f", "vec3f", "f32"], result: "vec3f" }, (i: F32, n: F32, eta: number): F32 => { + const dotProduct = dot(n, i); + const k = 1.0 - eta * eta * (1.0 - dotProduct * dotProduct); + if (k < 0.0) { + return [0, 0, 0]; + } + const scaleFactor = eta * dotProduct + Math.sqrt(k); + return subtract(scale(i, eta), scale(n, scaleFactor)); +}); diff --git a/packages/ecs/src/interface/math/vec3/f32/round.ts b/packages/ecs/src/interface/math/vec3/f32/round.ts new file mode 100644 index 000000000..19ba3bd9a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/round.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { roundHalfEven } from "../../f32/round-half-even.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const round = intrinsic({ wgsl: "round", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [ + roundHalfEven(x), + roundHalfEven(y), + roundHalfEven(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/scale.ts b/packages/ecs/src/interface/math/vec3/f32/scale.ts new file mode 100644 index 000000000..7fe1faa63 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/scale.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec3f", "f32"], result: "vec3f" }, ([x, y, z]: F32, s: number): F32 => [x * s, y * s, z * s]); diff --git a/packages/ecs/src/interface/math/vec3/f32/schema.ts b/packages/ecs/src/interface/math/vec3/f32/schema.ts new file mode 100644 index 000000000..fce65caa6 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../f32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 3, maxItems: 3 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec3/f32/sign.ts b/packages/ecs/src/interface/math/vec3/f32/sign.ts new file mode 100644 index 000000000..c58c61636 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/sign.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sign = intrinsic({ wgsl: "sign", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.sign(x), Math.sign(y), Math.sign(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/sin.ts b/packages/ecs/src/interface/math/vec3/f32/sin.ts new file mode 100644 index 000000000..a8932ed02 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/sin.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sin = intrinsic({ wgsl: "sin", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.sin(x), Math.sin(y), Math.sin(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/sinh.ts b/packages/ecs/src/interface/math/vec3/f32/sinh.ts new file mode 100644 index 000000000..f1a06c985 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/sinh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sinh = intrinsic({ wgsl: "sinh", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.sinh(x), Math.sinh(y), Math.sinh(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/smoothstep.ts b/packages/ecs/src/interface/math/vec3/f32/smoothstep.ts new file mode 100644 index 000000000..16d78b878 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/smoothstep.ts @@ -0,0 +1,13 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const smoothstep = intrinsic({ wgsl: "smoothstep", parameters: ["vec3f", "vec3f", "vec3f"], result: "vec3f" }, ([e0x, e0y, e0z]: F32, [e1x, e1y, e1z]: F32, [x, y, z]: F32): F32 => { + const tx = Math.max(0, Math.min(1, (x - e0x) / (e1x - e0x))); + const ty = Math.max(0, Math.min(1, (y - e0y) / (e1y - e0y))); + const tz = Math.max(0, Math.min(1, (z - e0z) / (e1z - e0z))); + return [ + tx * tx * (3 - 2 * tx), + ty * ty * (3 - 2 * ty), + tz * tz * (3 - 2 * tz) + ]; +}); diff --git a/packages/ecs/src/interface/math/vec3/f32/sqrt.ts b/packages/ecs/src/interface/math/vec3/f32/sqrt.ts new file mode 100644 index 000000000..7e3478a35 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/sqrt.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sqrt = intrinsic({ wgsl: "sqrt", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.sqrt(x), Math.sqrt(y), Math.sqrt(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/step.ts b/packages/ecs/src/interface/math/vec3/f32/step.ts new file mode 100644 index 000000000..44c283151 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/step.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const step = intrinsic({ wgsl: "step", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([edge1, edge2, edge3]: F32, [x, y, z]: F32): F32 => [ + x < edge1 ? 0 : 1, + y < edge2 ? 0 : 1, + z < edge3 ? 0 : 1 +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/subtract.ts b/packages/ecs/src/interface/math/vec3/f32/subtract.ts new file mode 100644 index 000000000..22cec835a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/subtract.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec3f", "vec3f"], result: "vec3f" }, ([x1, y1, z1]: F32, [x2, y2, z2]: F32): F32 => [ + x1 - x2, + y1 - y2, + z1 - z2 +]); diff --git a/packages/ecs/src/interface/math/vec3/f32/tan.ts b/packages/ecs/src/interface/math/vec3/f32/tan.ts new file mode 100644 index 000000000..d1bb8d978 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/tan.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const tan = intrinsic({ wgsl: "tan", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.tan(x), Math.tan(y), Math.tan(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/tanh.ts b/packages/ecs/src/interface/math/vec3/f32/tanh.ts new file mode 100644 index 000000000..531bf9005 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/tanh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const tanh = intrinsic({ wgsl: "tanh", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.tanh(x), Math.tanh(y), Math.tanh(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/trunc.ts b/packages/ecs/src/interface/math/vec3/f32/trunc.ts new file mode 100644 index 000000000..a5611b03a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/trunc.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const trunc = intrinsic({ wgsl: "trunc", parameters: ["vec3f"], result: "vec3f" }, ([x, y, z]: F32): F32 => [Math.trunc(x), Math.trunc(y), Math.trunc(z)]); diff --git a/packages/ecs/src/interface/math/vec3/f32/zero.ts b/packages/ecs/src/interface/math/vec3/f32/zero.ts new file mode 100644 index 000000000..01e696db6 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/f32/zero.ts @@ -0,0 +1,3 @@ +import type { F32 } from "./f32.js"; + +export const zero: F32 = [0, 0, 0]; diff --git a/packages/ecs/src/interface/math/vec3/i32/abs.ts b/packages/ecs/src/interface/math/vec3/i32/abs.ts new file mode 100644 index 000000000..f424815ac --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/abs.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const abs = intrinsic({ wgsl: "abs", parameters: ["vec3i"], result: "vec3i" }, ([x, y, z]: Vec3_I32): Vec3_I32 => [ + Math.abs(x) | 0, + Math.abs(y) | 0, + Math.abs(z) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/add.ts b/packages/ecs/src/interface/math/vec3/i32/add.ts new file mode 100644 index 000000000..f5e87005f --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/add.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + (x1 + x2) | 0, + (y1 + y2) | 0, + (z1 + z2) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/bitwise-and.ts b/packages/ecs/src/interface/math/vec3/i32/bitwise-and.ts new file mode 100644 index 000000000..b53bb6115 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/bitwise-and.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseAnd = intrinsic({ wgsl: "&", form: "infix", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + x1 & x2, + y1 & y2, + z1 & z2, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/bitwise-not.ts b/packages/ecs/src/interface/math/vec3/i32/bitwise-not.ts new file mode 100644 index 000000000..ebb58199a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/bitwise-not.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseNot = intrinsic({ wgsl: "~", form: "prefix", parameters: ["vec3i"], result: "vec3i" }, ([x, y, z]: Vec3_I32): Vec3_I32 => [~x, ~y, ~z]); diff --git a/packages/ecs/src/interface/math/vec3/i32/bitwise-or.ts b/packages/ecs/src/interface/math/vec3/i32/bitwise-or.ts new file mode 100644 index 000000000..554eba70e --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/bitwise-or.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseOr = intrinsic({ wgsl: "|", form: "infix", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + x1 | x2, + y1 | y2, + z1 | z2, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/bitwise-xor.ts b/packages/ecs/src/interface/math/vec3/i32/bitwise-xor.ts new file mode 100644 index 000000000..43452a19d --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/bitwise-xor.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseXor = intrinsic({ wgsl: "^", form: "infix", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + x1 ^ x2, + y1 ^ y2, + z1 ^ z2, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/clamp.ts b/packages/ecs/src/interface/math/vec3/i32/clamp.ts new file mode 100644 index 000000000..bce978af1 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/clamp.ts @@ -0,0 +1,6 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec3i", "vec3i", "vec3i"], result: "vec3i" }, (v: Vec3_I32, minVec: Vec3_I32, maxVec: Vec3_I32): Vec3_I32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec3/i32/count-leading-zeros.ts b/packages/ecs/src/interface/math/vec3/i32/count-leading-zeros.ts new file mode 100644 index 000000000..0e15bfaca --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/count-leading-zeros.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countLeadingZeros = intrinsic({ wgsl: "countLeadingZeros", parameters: ["vec3i"], result: "vec3i" }, ([x, y, z]: Vec3_I32): Vec3_I32 => [ + U32.countLeadingZeros(x), + U32.countLeadingZeros(y), + U32.countLeadingZeros(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/count-one-bits.ts b/packages/ecs/src/interface/math/vec3/i32/count-one-bits.ts new file mode 100644 index 000000000..11f3f4f79 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/count-one-bits.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countOneBits = intrinsic({ wgsl: "countOneBits", parameters: ["vec3i"], result: "vec3i" }, ([x, y, z]: Vec3_I32): Vec3_I32 => [ + U32.countOneBits(x), + U32.countOneBits(y), + U32.countOneBits(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/count-trailing-zeros.ts b/packages/ecs/src/interface/math/vec3/i32/count-trailing-zeros.ts new file mode 100644 index 000000000..1020d410e --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/count-trailing-zeros.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countTrailingZeros = intrinsic({ wgsl: "countTrailingZeros", parameters: ["vec3i"], result: "vec3i" }, ([x, y, z]: Vec3_I32): Vec3_I32 => [ + U32.countTrailingZeros(x), + U32.countTrailingZeros(y), + U32.countTrailingZeros(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/equals.ts b/packages/ecs/src/interface/math/vec3/i32/equals.ts new file mode 100644 index 000000000..af49bb3b3 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/equals.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; + +export const equals = (a: Vec3_I32, b: Vec3_I32): boolean => a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; diff --git a/packages/ecs/src/interface/math/vec3/i32/i32.ts b/packages/ecs/src/interface/math/vec3/i32/i32.ts new file mode 100644 index 000000000..da0480d5f --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/i32.ts @@ -0,0 +1,4 @@ +/** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ +export type I32 = readonly [number, number, number]; + +export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/i32/max.ts b/packages/ecs/src/interface/math/vec3/i32/max.ts new file mode 100644 index 000000000..8c844455c --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/max.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + Math.max(x1, x2), + Math.max(y1, y2), + Math.max(z1, z2), +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/min.ts b/packages/ecs/src/interface/math/vec3/i32/min.ts new file mode 100644 index 000000000..a716f0d1e --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/min.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + Math.min(x1, x2), + Math.min(y1, y2), + Math.min(z1, z2), +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/multiply.ts b/packages/ecs/src/interface/math/vec3/i32/multiply.ts new file mode 100644 index 000000000..7d52efe10 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/multiply.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const multiply = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + Math.imul(x1, x2), + Math.imul(y1, y2), + Math.imul(z1, z2), +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/negate.ts b/packages/ecs/src/interface/math/vec3/i32/negate.ts new file mode 100644 index 000000000..acc3c0774 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/negate.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const negate = intrinsic({ wgsl: "-", form: "prefix", parameters: ["vec3i"], result: "vec3i" }, ([x, y, z]: Vec3_I32): Vec3_I32 => [ + (-x) | 0, + (-y) | 0, + (-z) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/one.ts b/packages/ecs/src/interface/math/vec3/i32/one.ts new file mode 100644 index 000000000..5a1146fb0 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/one.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; + +export const one: Vec3_I32 = [1, 1, 1]; diff --git a/packages/ecs/src/interface/math/vec3/i32/public.ts b/packages/ecs/src/interface/math/vec3/i32/public.ts new file mode 100644 index 000000000..7f3d8aee4 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/public.ts @@ -0,0 +1,23 @@ +export * from "./abs.js"; +export * from "./add.js"; +export * from "./bitwise-and.js"; +export * from "./bitwise-not.js"; +export * from "./bitwise-or.js"; +export * from "./bitwise-xor.js"; +export * from "./clamp.js"; +export * from "./count-leading-zeros.js"; +export * from "./count-one-bits.js"; +export * from "./count-trailing-zeros.js"; +export * from "./equals.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./multiply.js"; +export * from "./negate.js"; +export * from "./one.js"; +export * from "./reverse-bits.js"; +export * from "./scale.js"; +export * from "./shift-left.js"; +export * from "./shift-right.js"; +export * from "./subtract.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec3/i32/reverse-bits.ts b/packages/ecs/src/interface/math/vec3/i32/reverse-bits.ts new file mode 100644 index 000000000..3803c1dcc --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/reverse-bits.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reverseBits = intrinsic({ wgsl: "reverseBits", parameters: ["vec3i"], result: "vec3i" }, ([x, y, z]: Vec3_I32): Vec3_I32 => [ + U32.reverseBits(x) | 0, + U32.reverseBits(y) | 0, + U32.reverseBits(z) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/scale.ts b/packages/ecs/src/interface/math/vec3/i32/scale.ts new file mode 100644 index 000000000..420e76bc8 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/scale.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; +import type { I32 } from "../../i32/index.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec3i", "i32"], result: "vec3i" }, ([x, y, z]: Vec3_I32, s: I32): Vec3_I32 => [ + Math.imul(x, s), + Math.imul(y, s), + Math.imul(z, s), +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/schema.ts b/packages/ecs/src/interface/math/vec3/i32/schema.ts new file mode 100644 index 000000000..ad3823d9e --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../i32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 3, maxItems: 3 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec3/i32/shift-left.ts b/packages/ecs/src/interface/math/vec3/i32/shift-left.ts new file mode 100644 index 000000000..1e4f4a110 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/shift-left.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; + +export const shiftLeft = ([x, y, z]: Vec3_I32, s: number): Vec3_I32 => [x << s, y << s, z << s]; diff --git a/packages/ecs/src/interface/math/vec3/i32/shift-right.ts b/packages/ecs/src/interface/math/vec3/i32/shift-right.ts new file mode 100644 index 000000000..0d7b71061 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/shift-right.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { I32 } from "../../i32/index.js"; + +export const shiftRight = ([x, y, z]: Vec3_I32, s: number): Vec3_I32 => [ + I32.shiftRight(x, s), + I32.shiftRight(y, s), + I32.shiftRight(z, s), +]; diff --git a/packages/ecs/src/interface/math/vec3/i32/subtract.ts b/packages/ecs/src/interface/math/vec3/i32/subtract.ts new file mode 100644 index 000000000..913ff0168 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/subtract.ts @@ -0,0 +1,8 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec3i", "vec3i"], result: "vec3i" }, ([x1, y1, z1]: Vec3_I32, [x2, y2, z2]: Vec3_I32): Vec3_I32 => [ + (x1 - x2) | 0, + (y1 - y2) | 0, + (z1 - z2) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/i32/zero.ts b/packages/ecs/src/interface/math/vec3/i32/zero.ts new file mode 100644 index 000000000..6257492ca --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/i32/zero.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec3_I32 } from "./i32.js"; + +export const zero: Vec3_I32 = [0, 0, 0]; diff --git a/packages/ecs/src/interface/math/vec3/index.ts b/packages/ecs/src/interface/math/vec3/index.ts new file mode 100644 index 000000000..f2e2b96a0 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/index.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32/f32.js"; + +/** Vec3 is `Vec3.F32`; `Vec3.I32` and `Vec3.U32` hold integer vectors. */ +export type Vec3 = F32; + +export * as Vec3 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/public.ts b/packages/ecs/src/interface/math/vec3/public.ts new file mode 100644 index 000000000..c701852dd --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/public.ts @@ -0,0 +1,4 @@ +export { F32 } from "./f32/f32.js"; +export { I32 } from "./i32/i32.js"; +export { U32 } from "./u32/u32.js"; +export * from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec3/u32/add.ts b/packages/ecs/src/interface/math/vec3/u32/add.ts new file mode 100644 index 000000000..88b1386e3 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/add.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + (x1 + x2) >>> 0, + (y1 + y2) >>> 0, + (z1 + z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/bitwise-and.ts b/packages/ecs/src/interface/math/vec3/u32/bitwise-and.ts new file mode 100644 index 000000000..f2860142d --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/bitwise-and.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseAnd = intrinsic({ wgsl: "&", form: "infix", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + (x1 & x2) >>> 0, + (y1 & y2) >>> 0, + (z1 & z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/bitwise-not.ts b/packages/ecs/src/interface/math/vec3/u32/bitwise-not.ts new file mode 100644 index 000000000..b9da6c345 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/bitwise-not.ts @@ -0,0 +1,4 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseNot = intrinsic({ wgsl: "~", form: "prefix", parameters: ["vec3u"], result: "vec3u" }, ([x, y, z]: Vec3_U32): Vec3_U32 => [(~x) >>> 0, (~y) >>> 0, (~z) >>> 0]); diff --git a/packages/ecs/src/interface/math/vec3/u32/bitwise-or.ts b/packages/ecs/src/interface/math/vec3/u32/bitwise-or.ts new file mode 100644 index 000000000..cc5d15a20 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/bitwise-or.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseOr = intrinsic({ wgsl: "|", form: "infix", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + (x1 | x2) >>> 0, + (y1 | y2) >>> 0, + (z1 | z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/bitwise-xor.ts b/packages/ecs/src/interface/math/vec3/u32/bitwise-xor.ts new file mode 100644 index 000000000..e2b554084 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/bitwise-xor.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseXor = intrinsic({ wgsl: "^", form: "infix", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + (x1 ^ x2) >>> 0, + (y1 ^ y2) >>> 0, + (z1 ^ z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/clamp.ts b/packages/ecs/src/interface/math/vec3/u32/clamp.ts new file mode 100644 index 000000000..991062deb --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/clamp.ts @@ -0,0 +1,6 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec3u", "vec3u", "vec3u"], result: "vec3u" }, (v: Vec3_U32, minVec: Vec3_U32, maxVec: Vec3_U32): Vec3_U32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec3/u32/count-leading-zeros.ts b/packages/ecs/src/interface/math/vec3/u32/count-leading-zeros.ts new file mode 100644 index 000000000..290390bbd --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/count-leading-zeros.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countLeadingZeros = intrinsic({ wgsl: "countLeadingZeros", parameters: ["vec3u"], result: "vec3u" }, ([x, y, z]: Vec3_U32): Vec3_U32 => [ + U32.countLeadingZeros(x), + U32.countLeadingZeros(y), + U32.countLeadingZeros(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/count-one-bits.ts b/packages/ecs/src/interface/math/vec3/u32/count-one-bits.ts new file mode 100644 index 000000000..8ca1d9409 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/count-one-bits.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countOneBits = intrinsic({ wgsl: "countOneBits", parameters: ["vec3u"], result: "vec3u" }, ([x, y, z]: Vec3_U32): Vec3_U32 => [ + U32.countOneBits(x), + U32.countOneBits(y), + U32.countOneBits(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/count-trailing-zeros.ts b/packages/ecs/src/interface/math/vec3/u32/count-trailing-zeros.ts new file mode 100644 index 000000000..a58b16dc6 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/count-trailing-zeros.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countTrailingZeros = intrinsic({ wgsl: "countTrailingZeros", parameters: ["vec3u"], result: "vec3u" }, ([x, y, z]: Vec3_U32): Vec3_U32 => [ + U32.countTrailingZeros(x), + U32.countTrailingZeros(y), + U32.countTrailingZeros(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/equals.ts b/packages/ecs/src/interface/math/vec3/u32/equals.ts new file mode 100644 index 000000000..c1dd0922f --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/equals.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; + +export const equals = (a: Vec3_U32, b: Vec3_U32): boolean => a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; diff --git a/packages/ecs/src/interface/math/vec3/u32/max.ts b/packages/ecs/src/interface/math/vec3/u32/max.ts new file mode 100644 index 000000000..db4733eb3 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/max.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + Math.max(x1, x2) >>> 0, + Math.max(y1, y2) >>> 0, + Math.max(z1, z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/min.ts b/packages/ecs/src/interface/math/vec3/u32/min.ts new file mode 100644 index 000000000..b7758f52f --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/min.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + Math.min(x1, x2) >>> 0, + Math.min(y1, y2) >>> 0, + Math.min(z1, z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/multiply.ts b/packages/ecs/src/interface/math/vec3/u32/multiply.ts new file mode 100644 index 000000000..b07d23f21 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/multiply.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const multiply = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + Math.imul(x1, x2) >>> 0, + Math.imul(y1, y2) >>> 0, + Math.imul(z1, z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/negate.ts b/packages/ecs/src/interface/math/vec3/u32/negate.ts new file mode 100644 index 000000000..16b9dc7e7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/negate.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; + +export const negate = ([x, y, z]: Vec3_U32): Vec3_U32 => [(-x) >>> 0, (-y) >>> 0, (-z) >>> 0]; diff --git a/packages/ecs/src/interface/math/vec3/u32/one.ts b/packages/ecs/src/interface/math/vec3/u32/one.ts new file mode 100644 index 000000000..47e4c82d7 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/one.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; + +export const one: Vec3_U32 = [1, 1, 1]; diff --git a/packages/ecs/src/interface/math/vec3/u32/public.ts b/packages/ecs/src/interface/math/vec3/u32/public.ts new file mode 100644 index 000000000..608c2d903 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/public.ts @@ -0,0 +1,23 @@ +export * from "./add.js"; +export * from "./bitwise-and.js"; +export * from "./bitwise-not.js"; +export * from "./bitwise-or.js"; +export * from "./bitwise-xor.js"; +export * from "./clamp.js"; +export * from "./count-leading-zeros.js"; +export * from "./count-one-bits.js"; +export * from "./count-trailing-zeros.js"; +export * from "./equals.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./multiply.js"; +export * from "./negate.js"; +export * from "./one.js"; +export * from "./reverse-bits.js"; +export * from "./scale.js"; +export * from "./shift-left.js"; +export * from "./shift-right.js"; +export * from "./subtract.js"; +export * from "./volume.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec3/u32/reverse-bits.ts b/packages/ecs/src/interface/math/vec3/u32/reverse-bits.ts new file mode 100644 index 000000000..a406870d6 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/reverse-bits.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reverseBits = intrinsic({ wgsl: "reverseBits", parameters: ["vec3u"], result: "vec3u" }, ([x, y, z]: Vec3_U32): Vec3_U32 => [ + U32.reverseBits(x), + U32.reverseBits(y), + U32.reverseBits(z), +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/scale.ts b/packages/ecs/src/interface/math/vec3/u32/scale.ts new file mode 100644 index 000000000..61130b72c --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/scale.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; +import type { U32 } from "../../u32/index.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec3u", "u32"], result: "vec3u" }, ([x, y, z]: Vec3_U32, s: U32): Vec3_U32 => [ + Math.imul(x, s) >>> 0, + Math.imul(y, s) >>> 0, + Math.imul(z, s) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/schema.ts b/packages/ecs/src/interface/math/vec3/u32/schema.ts new file mode 100644 index 000000000..ae8a40f6a --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../u32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 3, maxItems: 3 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec3/u32/shift-left.ts b/packages/ecs/src/interface/math/vec3/u32/shift-left.ts new file mode 100644 index 000000000..477cc6420 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/shift-left.ts @@ -0,0 +1,7 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; + +export const shiftLeft = ([x, y, z]: Vec3_U32, s: number): Vec3_U32 => [ + (x << s) >>> 0, + (y << s) >>> 0, + (z << s) >>> 0, +]; diff --git a/packages/ecs/src/interface/math/vec3/u32/shift-right.ts b/packages/ecs/src/interface/math/vec3/u32/shift-right.ts new file mode 100644 index 000000000..b61158440 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/shift-right.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; + +export const shiftRight = ([x, y, z]: Vec3_U32, s: number): Vec3_U32 => [ + U32.shiftRight(x, s), + U32.shiftRight(y, s), + U32.shiftRight(z, s), +]; diff --git a/packages/ecs/src/interface/math/vec3/u32/subtract.ts b/packages/ecs/src/interface/math/vec3/u32/subtract.ts new file mode 100644 index 000000000..1bd4f099c --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/subtract.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec3u", "vec3u"], result: "vec3u" }, ([x1, y1, z1]: Vec3_U32, [x2, y2, z2]: Vec3_U32): Vec3_U32 => [ + (x1 - x2) >>> 0, + (y1 - y2) >>> 0, + (z1 - z2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec3/u32/u32.ts b/packages/ecs/src/interface/math/vec3/u32/u32.ts new file mode 100644 index 000000000..6b5032b8f --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/u32.ts @@ -0,0 +1,4 @@ +/** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ +export type U32 = readonly [number, number, number]; + +export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/u32/volume.ts b/packages/ecs/src/interface/math/vec3/u32/volume.ts new file mode 100644 index 000000000..e1aa67864 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/volume.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; + +export const volume = ([x, y, z]: Vec3_U32): number => Math.imul(Math.imul(x, y), z) >>> 0; diff --git a/packages/ecs/src/interface/math/vec3/u32/zero.ts b/packages/ecs/src/interface/math/vec3/u32/zero.ts new file mode 100644 index 000000000..f20d31433 --- /dev/null +++ b/packages/ecs/src/interface/math/vec3/u32/zero.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec3_U32 } from "./u32.js"; + +export const zero: Vec3_U32 = [0, 0, 0]; diff --git a/packages/ecs/src/interface/math/vec4/f32/abs.ts b/packages/ecs/src/interface/math/vec4/f32/abs.ts new file mode 100644 index 000000000..cf2061426 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/abs.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const abs = intrinsic({ wgsl: "abs", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.abs(x), Math.abs(y), Math.abs(z), Math.abs(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/acos.ts b/packages/ecs/src/interface/math/vec4/f32/acos.ts new file mode 100644 index 000000000..c5c9e460f --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/acos.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const acos = intrinsic({ wgsl: "acos", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.acos(x), Math.acos(y), Math.acos(z), Math.acos(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/acosh.ts b/packages/ecs/src/interface/math/vec4/f32/acosh.ts new file mode 100644 index 000000000..d77571455 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/acosh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const acosh = intrinsic({ wgsl: "acosh", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.acosh(x), Math.acosh(y), Math.acosh(z), Math.acosh(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/add.ts b/packages/ecs/src/interface/math/vec4/f32/add.ts new file mode 100644 index 000000000..a993f7ac8 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/add.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32): F32 => [ + x1 + x2, + y1 + y2, + z1 + z2, + w1 + w2 +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/asin.ts b/packages/ecs/src/interface/math/vec4/f32/asin.ts new file mode 100644 index 000000000..dd7af94c5 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/asin.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const asin = intrinsic({ wgsl: "asin", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.asin(x), Math.asin(y), Math.asin(z), Math.asin(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/asinh.ts b/packages/ecs/src/interface/math/vec4/f32/asinh.ts new file mode 100644 index 000000000..6b58c28a1 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/asinh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const asinh = intrinsic({ wgsl: "asinh", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.asinh(x), Math.asinh(y), Math.asinh(z), Math.asinh(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/atan.ts b/packages/ecs/src/interface/math/vec4/f32/atan.ts new file mode 100644 index 000000000..f8f78af53 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/atan.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const atan = intrinsic({ wgsl: "atan", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.atan(x), Math.atan(y), Math.atan(z), Math.atan(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/atanh.ts b/packages/ecs/src/interface/math/vec4/f32/atanh.ts new file mode 100644 index 000000000..91e14369b --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/atanh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const atanh = intrinsic({ wgsl: "atanh", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.atanh(x), Math.atanh(y), Math.atanh(z), Math.atanh(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/ceil.ts b/packages/ecs/src/interface/math/vec4/f32/ceil.ts new file mode 100644 index 000000000..8b61a2a15 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/ceil.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const ceil = intrinsic({ wgsl: "ceil", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.ceil(x), Math.ceil(y), Math.ceil(z), Math.ceil(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/clamp.ts b/packages/ecs/src/interface/math/vec4/f32/clamp.ts new file mode 100644 index 000000000..e278daa8b --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/clamp.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec4f", "vec4f", "vec4f"], result: "vec4f" }, (v: F32, minVec: F32, maxVec: F32): F32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec4/f32/cos.ts b/packages/ecs/src/interface/math/vec4/f32/cos.ts new file mode 100644 index 000000000..32798503d --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/cos.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const cos = intrinsic({ wgsl: "cos", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.cos(x), Math.cos(y), Math.cos(z), Math.cos(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/cosh.ts b/packages/ecs/src/interface/math/vec4/f32/cosh.ts new file mode 100644 index 000000000..dbd608414 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/cosh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const cosh = intrinsic({ wgsl: "cosh", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.cosh(x), Math.cosh(y), Math.cosh(z), Math.cosh(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/distance.ts b/packages/ecs/src/interface/math/vec4/f32/distance.ts new file mode 100644 index 000000000..78d3bc75b --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/distance.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { length } from "./length.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const distance = intrinsic({ wgsl: "distance", parameters: ["vec4f", "vec4f"], result: "f32" }, (a: F32, b: F32): number => length(subtract(b, a))); diff --git a/packages/ecs/src/interface/math/vec4/f32/dot.ts b/packages/ecs/src/interface/math/vec4/f32/dot.ts new file mode 100644 index 000000000..d8a00d2fa --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/dot.ts @@ -0,0 +1,5 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const dot = intrinsic({ wgsl: "dot", parameters: ["vec4f", "vec4f"], result: "f32" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32): number => + x1 * x2 + y1 * y2 + z1 * z2 + w1 * w2); diff --git a/packages/ecs/src/interface/math/vec4/f32/exp.ts b/packages/ecs/src/interface/math/vec4/f32/exp.ts new file mode 100644 index 000000000..502e1bbf6 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/exp.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const exp = intrinsic({ wgsl: "exp", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.exp(x), Math.exp(y), Math.exp(z), Math.exp(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/exp2.ts b/packages/ecs/src/interface/math/vec4/f32/exp2.ts new file mode 100644 index 000000000..c3aab7651 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/exp2.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const exp2 = intrinsic({ wgsl: "exp2", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.pow(2, x), Math.pow(2, y), Math.pow(2, z), Math.pow(2, w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/f32.ts b/packages/ecs/src/interface/math/vec4/f32/f32.ts new file mode 100644 index 000000000..735c719b3 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/f32.ts @@ -0,0 +1,3 @@ +export type F32 = readonly [number, number, number, number]; + +export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/f32/faceforward.ts b/packages/ecs/src/interface/math/vec4/f32/faceforward.ts new file mode 100644 index 000000000..8849c42ac --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/faceforward.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { negate } from "./negate.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const faceforward = intrinsic({ wgsl: "faceForward", parameters: ["vec4f", "vec4f", "vec4f"], result: "vec4f" }, (n: F32, i: F32, nref: F32): F32 => + dot(nref, i) < 0 ? n : negate(n)); diff --git a/packages/ecs/src/interface/math/vec4/f32/floor.ts b/packages/ecs/src/interface/math/vec4/f32/floor.ts new file mode 100644 index 000000000..c6945698c --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/floor.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const floor = intrinsic({ wgsl: "floor", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.floor(x), Math.floor(y), Math.floor(z), Math.floor(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/fract.ts b/packages/ecs/src/interface/math/vec4/f32/fract.ts new file mode 100644 index 000000000..82e1c96bd --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/fract.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const fract = intrinsic({ wgsl: "fract", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [ + x - Math.floor(x), + y - Math.floor(y), + z - Math.floor(z), + w - Math.floor(w) +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/length.ts b/packages/ecs/src/interface/math/vec4/f32/length.ts new file mode 100644 index 000000000..d1caabbee --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/length.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const length = intrinsic({ wgsl: "length", parameters: ["vec4f"], result: "f32" }, ([x, y, z, w]: F32): number => Math.sqrt(x * x + y * y + z * z + w * w)); diff --git a/packages/ecs/src/interface/math/vec4/f32/log.ts b/packages/ecs/src/interface/math/vec4/f32/log.ts new file mode 100644 index 000000000..eada272de --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/log.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const log = intrinsic({ wgsl: "log", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.log(x), Math.log(y), Math.log(z), Math.log(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/log2.ts b/packages/ecs/src/interface/math/vec4/f32/log2.ts new file mode 100644 index 000000000..c9e644ffa --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/log2.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const log2 = intrinsic({ wgsl: "log2", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.log2(x), Math.log2(y), Math.log2(z), Math.log2(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/max.ts b/packages/ecs/src/interface/math/vec4/f32/max.ts new file mode 100644 index 000000000..b6c366fee --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/max.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32): F32 => [ + Math.max(x1, x2), + Math.max(y1, y2), + Math.max(z1, z2), + Math.max(w1, w2) +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/min.ts b/packages/ecs/src/interface/math/vec4/f32/min.ts new file mode 100644 index 000000000..f00eaf7e4 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/min.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32): F32 => [ + Math.min(x1, x2), + Math.min(y1, y2), + Math.min(z1, z2), + Math.min(w1, w2) +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/mix.ts b/packages/ecs/src/interface/math/vec4/f32/mix.ts new file mode 100644 index 000000000..e7eda2e36 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/mix.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const mix = intrinsic({ wgsl: "mix", parameters: ["vec4f", "vec4f", "f32"], result: "vec4f" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32, t: number): F32 => [ + x1 * (1 - t) + x2 * t, + y1 * (1 - t) + y2 * t, + z1 * (1 - t) + z2 * t, + w1 * (1 - t) + w2 * t +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/mod.ts b/packages/ecs/src/interface/math/vec4/f32/mod.ts new file mode 100644 index 000000000..28fa85eb3 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/mod.ts @@ -0,0 +1,8 @@ +import type { F32 } from "./f32.js"; + +export const mod = ([x, y, z, w]: F32, m: number): F32 => [ + x - m * Math.floor(x / m), + y - m * Math.floor(y / m), + z - m * Math.floor(z / m), + w - m * Math.floor(w / m), +]; diff --git a/packages/ecs/src/interface/math/vec4/f32/modf.ts b/packages/ecs/src/interface/math/vec4/f32/modf.ts new file mode 100644 index 000000000..f5c8df67b --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/modf.ts @@ -0,0 +1,7 @@ +import type { F32 } from "./f32.js"; +import { fract } from "./fract.js"; + +export const modf = ([x, y, z, w]: F32): { fract: F32; whole: F32 } => ({ + whole: [Math.trunc(x), Math.trunc(y), Math.trunc(z), Math.trunc(w)], + fract: [x - Math.trunc(x), y - Math.trunc(y), z - Math.trunc(z), w - Math.trunc(w)] +}); diff --git a/packages/ecs/src/interface/math/vec4/f32/multiply.ts b/packages/ecs/src/interface/math/vec4/f32/multiply.ts new file mode 100644 index 000000000..50048217d --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/multiply.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const multiply = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32): F32 => [ + x1 * x2, + y1 * y2, + z1 * z2, + w1 * w2 +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/negate.ts b/packages/ecs/src/interface/math/vec4/f32/negate.ts new file mode 100644 index 000000000..6c995a176 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/negate.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const negate = intrinsic({ wgsl: "-", form: "prefix", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [-x, -y, -z, -w]); diff --git a/packages/ecs/src/interface/math/vec4/f32/normalize.ts b/packages/ecs/src/interface/math/vec4/f32/normalize.ts new file mode 100644 index 000000000..1a5a77ea2 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/normalize.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32.js"; +import { length } from "./length.js"; +import { scale } from "./scale.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const normalize = intrinsic({ wgsl: "normalize", parameters: ["vec4f"], result: "vec4f" }, (v: F32): F32 => scale(v, 1 / length(v))); diff --git a/packages/ecs/src/interface/math/vec4/f32/pow.ts b/packages/ecs/src/interface/math/vec4/f32/pow.ts new file mode 100644 index 000000000..895247f28 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/pow.ts @@ -0,0 +1,10 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +/** Each lane of the first vector to the power of the second's; as WGSL's `pow`, indeterminate for a negative base, or a zero base with an exponent at most zero. */ +export const pow = intrinsic({ wgsl: "pow", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32): F32 => [ + Math.pow(x1, x2), + Math.pow(y1, y2), + Math.pow(z1, z2), + Math.pow(w1, w2) +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/public.ts b/packages/ecs/src/interface/math/vec4/f32/public.ts new file mode 100644 index 000000000..4bbf1ad14 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/public.ts @@ -0,0 +1,46 @@ +export * from "./abs.js"; +export * from "./acos.js"; +export * from "./acosh.js"; +export * from "./add.js"; +export * from "./asin.js"; +export * from "./asinh.js"; +export * from "./atan.js"; +export * from "./atanh.js"; +export * from "./ceil.js"; +export * from "./clamp.js"; +export * from "./cos.js"; +export * from "./cosh.js"; +export * from "./distance.js"; +export * from "./dot.js"; +export * from "./exp.js"; +export * from "./exp2.js"; +export * from "./faceforward.js"; +export * from "./floor.js"; +export * from "./fract.js"; +export * from "./length.js"; +export * from "./log.js"; +export * from "./log2.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./mix.js"; +export * from "./mod.js"; +export * from "./modf.js"; +export * from "./multiply.js"; +export * from "./negate.js"; +export * from "./normalize.js"; +export * from "./pow.js"; +export * from "./reflect.js"; +export * from "./refract.js"; +export * from "./round.js"; +export * from "./scale.js"; +export * from "./sign.js"; +export * from "./sin.js"; +export * from "./sinh.js"; +export * from "./smoothstep.js"; +export * from "./sqrt.js"; +export * from "./step.js"; +export * from "./subtract.js"; +export * from "./tan.js"; +export * from "./tanh.js"; +export * from "./trunc.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec4/f32/reflect.ts b/packages/ecs/src/interface/math/vec4/f32/reflect.ts new file mode 100644 index 000000000..80d31c1ca --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/reflect.ts @@ -0,0 +1,10 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { scale } from "./scale.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reflect = intrinsic({ wgsl: "reflect", parameters: ["vec4f", "vec4f"], result: "vec4f" }, (i: F32, n: F32): F32 => { + const dot2 = dot(n, i) * 2; + return subtract(i, scale(n, dot2)); +}); diff --git a/packages/ecs/src/interface/math/vec4/f32/refract.ts b/packages/ecs/src/interface/math/vec4/f32/refract.ts new file mode 100644 index 000000000..d9be9b782 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/refract.ts @@ -0,0 +1,15 @@ +import type { F32 } from "./f32.js"; +import { dot } from "./dot.js"; +import { scale } from "./scale.js"; +import { subtract } from "./subtract.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const refract = intrinsic({ wgsl: "refract", parameters: ["vec4f", "vec4f", "f32"], result: "vec4f" }, (i: F32, n: F32, eta: number): F32 => { + const dotProduct = dot(n, i); + const k = 1.0 - eta * eta * (1.0 - dotProduct * dotProduct); + if (k < 0.0) { + return [0, 0, 0, 0]; + } + const scaleFactor = eta * dotProduct + Math.sqrt(k); + return subtract(scale(i, eta), scale(n, scaleFactor)); +}); diff --git a/packages/ecs/src/interface/math/vec4/f32/round.ts b/packages/ecs/src/interface/math/vec4/f32/round.ts new file mode 100644 index 000000000..2ef8e12ea --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/round.ts @@ -0,0 +1,10 @@ +import type { F32 } from "./f32.js"; +import { roundHalfEven } from "../../f32/round-half-even.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const round = intrinsic({ wgsl: "round", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [ + roundHalfEven(x), + roundHalfEven(y), + roundHalfEven(z), + roundHalfEven(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/scale.ts b/packages/ecs/src/interface/math/vec4/f32/scale.ts new file mode 100644 index 000000000..e62f687f1 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/scale.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec4f", "f32"], result: "vec4f" }, ([x, y, z, w]: F32, s: number): F32 => [x * s, y * s, z * s, w * s]); diff --git a/packages/ecs/src/interface/math/vec4/f32/schema.ts b/packages/ecs/src/interface/math/vec4/f32/schema.ts new file mode 100644 index 000000000..0012f510d --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../f32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 4, maxItems: 4 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec4/f32/sign.ts b/packages/ecs/src/interface/math/vec4/f32/sign.ts new file mode 100644 index 000000000..86d0ad189 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/sign.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sign = intrinsic({ wgsl: "sign", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.sign(x), Math.sign(y), Math.sign(z), Math.sign(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/sin.ts b/packages/ecs/src/interface/math/vec4/f32/sin.ts new file mode 100644 index 000000000..b37607355 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/sin.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sin = intrinsic({ wgsl: "sin", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.sin(x), Math.sin(y), Math.sin(z), Math.sin(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/sinh.ts b/packages/ecs/src/interface/math/vec4/f32/sinh.ts new file mode 100644 index 000000000..f4801bb56 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/sinh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sinh = intrinsic({ wgsl: "sinh", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.sinh(x), Math.sinh(y), Math.sinh(z), Math.sinh(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/smoothstep.ts b/packages/ecs/src/interface/math/vec4/f32/smoothstep.ts new file mode 100644 index 000000000..0fa7542c1 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/smoothstep.ts @@ -0,0 +1,15 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const smoothstep = intrinsic({ wgsl: "smoothstep", parameters: ["vec4f", "vec4f", "vec4f"], result: "vec4f" }, ([e0x, e0y, e0z, e0w]: F32, [e1x, e1y, e1z, e1w]: F32, [x, y, z, w]: F32): F32 => { + const tx = Math.max(0, Math.min(1, (x - e0x) / (e1x - e0x))); + const ty = Math.max(0, Math.min(1, (y - e0y) / (e1y - e0y))); + const tz = Math.max(0, Math.min(1, (z - e0z) / (e1z - e0z))); + const tw = Math.max(0, Math.min(1, (w - e0w) / (e1w - e0w))); + return [ + tx * tx * (3 - 2 * tx), + ty * ty * (3 - 2 * ty), + tz * tz * (3 - 2 * tz), + tw * tw * (3 - 2 * tw) + ]; +}); diff --git a/packages/ecs/src/interface/math/vec4/f32/sqrt.ts b/packages/ecs/src/interface/math/vec4/f32/sqrt.ts new file mode 100644 index 000000000..db467e769 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/sqrt.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const sqrt = intrinsic({ wgsl: "sqrt", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.sqrt(x), Math.sqrt(y), Math.sqrt(z), Math.sqrt(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/step.ts b/packages/ecs/src/interface/math/vec4/f32/step.ts new file mode 100644 index 000000000..5e093ce79 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/step.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const step = intrinsic({ wgsl: "step", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([edge1, edge2, edge3, edge4]: F32, [x, y, z, w]: F32): F32 => [ + x < edge1 ? 0 : 1, + y < edge2 ? 0 : 1, + z < edge3 ? 0 : 1, + w < edge4 ? 0 : 1 +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/subtract.ts b/packages/ecs/src/interface/math/vec4/f32/subtract.ts new file mode 100644 index 000000000..a2ed1c0ee --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/subtract.ts @@ -0,0 +1,9 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec4f", "vec4f"], result: "vec4f" }, ([x1, y1, z1, w1]: F32, [x2, y2, z2, w2]: F32): F32 => [ + x1 - x2, + y1 - y2, + z1 - z2, + w1 - w2 +]); diff --git a/packages/ecs/src/interface/math/vec4/f32/tan.ts b/packages/ecs/src/interface/math/vec4/f32/tan.ts new file mode 100644 index 000000000..618c861f8 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/tan.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const tan = intrinsic({ wgsl: "tan", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.tan(x), Math.tan(y), Math.tan(z), Math.tan(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/tanh.ts b/packages/ecs/src/interface/math/vec4/f32/tanh.ts new file mode 100644 index 000000000..1b5c6b44e --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/tanh.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const tanh = intrinsic({ wgsl: "tanh", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.tanh(x), Math.tanh(y), Math.tanh(z), Math.tanh(w)]); diff --git a/packages/ecs/src/interface/math/vec4/f32/trunc.ts b/packages/ecs/src/interface/math/vec4/f32/trunc.ts new file mode 100644 index 000000000..bcdbb793a --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/trunc.ts @@ -0,0 +1,4 @@ +import type { F32 } from "./f32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const trunc = intrinsic({ wgsl: "trunc", parameters: ["vec4f"], result: "vec4f" }, ([x, y, z, w]: F32): F32 => [Math.trunc(x), Math.trunc(y), Math.trunc(z), Math.trunc(w)]); diff --git a/packages/ecs/src/interface/math/vec4/i32/abs.ts b/packages/ecs/src/interface/math/vec4/i32/abs.ts new file mode 100644 index 000000000..13dc8be25 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/abs.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const abs = intrinsic({ wgsl: "abs", parameters: ["vec4i"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32): Vec4_I32 => [ + Math.abs(x) | 0, + Math.abs(y) | 0, + Math.abs(z) | 0, + Math.abs(w) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/add.ts b/packages/ecs/src/interface/math/vec4/i32/add.ts new file mode 100644 index 000000000..760a3c114 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/add.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + (x1 + x2) | 0, + (y1 + y2) | 0, + (z1 + z2) | 0, + (w1 + w2) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/bitwise-and.ts b/packages/ecs/src/interface/math/vec4/i32/bitwise-and.ts new file mode 100644 index 000000000..6ea31f64b --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/bitwise-and.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseAnd = intrinsic({ wgsl: "&", form: "infix", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + x1 & x2, + y1 & y2, + z1 & z2, + w1 & w2, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/bitwise-not.ts b/packages/ecs/src/interface/math/vec4/i32/bitwise-not.ts new file mode 100644 index 000000000..a7bf73f69 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/bitwise-not.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseNot = intrinsic({ wgsl: "~", form: "prefix", parameters: ["vec4i"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32): Vec4_I32 => [~x, ~y, ~z, ~w]); diff --git a/packages/ecs/src/interface/math/vec4/i32/bitwise-or.ts b/packages/ecs/src/interface/math/vec4/i32/bitwise-or.ts new file mode 100644 index 000000000..1ded4465f --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/bitwise-or.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseOr = intrinsic({ wgsl: "|", form: "infix", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + x1 | x2, + y1 | y2, + z1 | z2, + w1 | w2, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/bitwise-xor.ts b/packages/ecs/src/interface/math/vec4/i32/bitwise-xor.ts new file mode 100644 index 000000000..7114e8598 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/bitwise-xor.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseXor = intrinsic({ wgsl: "^", form: "infix", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + x1 ^ x2, + y1 ^ y2, + z1 ^ z2, + w1 ^ w2, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/clamp.ts b/packages/ecs/src/interface/math/vec4/i32/clamp.ts new file mode 100644 index 000000000..aedb6d55e --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/clamp.ts @@ -0,0 +1,6 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec4i", "vec4i", "vec4i"], result: "vec4i" }, (v: Vec4_I32, minVec: Vec4_I32, maxVec: Vec4_I32): Vec4_I32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec4/i32/count-leading-zeros.ts b/packages/ecs/src/interface/math/vec4/i32/count-leading-zeros.ts new file mode 100644 index 000000000..03deb3018 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/count-leading-zeros.ts @@ -0,0 +1,10 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countLeadingZeros = intrinsic({ wgsl: "countLeadingZeros", parameters: ["vec4i"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32): Vec4_I32 => [ + U32.countLeadingZeros(x), + U32.countLeadingZeros(y), + U32.countLeadingZeros(z), + U32.countLeadingZeros(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/count-one-bits.ts b/packages/ecs/src/interface/math/vec4/i32/count-one-bits.ts new file mode 100644 index 000000000..9c41e86ff --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/count-one-bits.ts @@ -0,0 +1,10 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countOneBits = intrinsic({ wgsl: "countOneBits", parameters: ["vec4i"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32): Vec4_I32 => [ + U32.countOneBits(x), + U32.countOneBits(y), + U32.countOneBits(z), + U32.countOneBits(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/count-trailing-zeros.ts b/packages/ecs/src/interface/math/vec4/i32/count-trailing-zeros.ts new file mode 100644 index 000000000..9473e605f --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/count-trailing-zeros.ts @@ -0,0 +1,10 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countTrailingZeros = intrinsic({ wgsl: "countTrailingZeros", parameters: ["vec4i"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32): Vec4_I32 => [ + U32.countTrailingZeros(x), + U32.countTrailingZeros(y), + U32.countTrailingZeros(z), + U32.countTrailingZeros(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/equals.ts b/packages/ecs/src/interface/math/vec4/i32/equals.ts new file mode 100644 index 000000000..cef25d5ea --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/equals.ts @@ -0,0 +1,4 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; + +export const equals = (a: Vec4_I32, b: Vec4_I32): boolean => + a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; diff --git a/packages/ecs/src/interface/math/vec4/i32/i32.ts b/packages/ecs/src/interface/math/vec4/i32/i32.ts new file mode 100644 index 000000000..a87449656 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/i32.ts @@ -0,0 +1,4 @@ +/** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ +export type I32 = readonly [number, number, number, number]; + +export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/i32/max.ts b/packages/ecs/src/interface/math/vec4/i32/max.ts new file mode 100644 index 000000000..834341eea --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/max.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + Math.max(x1, x2), + Math.max(y1, y2), + Math.max(z1, z2), + Math.max(w1, w2), +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/min.ts b/packages/ecs/src/interface/math/vec4/i32/min.ts new file mode 100644 index 000000000..9f8c954af --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/min.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + Math.min(x1, x2), + Math.min(y1, y2), + Math.min(z1, z2), + Math.min(w1, w2), +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/multiply.ts b/packages/ecs/src/interface/math/vec4/i32/multiply.ts new file mode 100644 index 000000000..010166a27 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/multiply.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const multiply = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + Math.imul(x1, x2), + Math.imul(y1, y2), + Math.imul(z1, z2), + Math.imul(w1, w2), +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/negate.ts b/packages/ecs/src/interface/math/vec4/i32/negate.ts new file mode 100644 index 000000000..422554ffb --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/negate.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const negate = intrinsic({ wgsl: "-", form: "prefix", parameters: ["vec4i"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32): Vec4_I32 => [ + (-x) | 0, + (-y) | 0, + (-z) | 0, + (-w) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/one.ts b/packages/ecs/src/interface/math/vec4/i32/one.ts new file mode 100644 index 000000000..eb7aa3642 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/one.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; + +export const one: Vec4_I32 = [1, 1, 1, 1]; diff --git a/packages/ecs/src/interface/math/vec4/i32/public.ts b/packages/ecs/src/interface/math/vec4/i32/public.ts new file mode 100644 index 000000000..7f3d8aee4 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/public.ts @@ -0,0 +1,23 @@ +export * from "./abs.js"; +export * from "./add.js"; +export * from "./bitwise-and.js"; +export * from "./bitwise-not.js"; +export * from "./bitwise-or.js"; +export * from "./bitwise-xor.js"; +export * from "./clamp.js"; +export * from "./count-leading-zeros.js"; +export * from "./count-one-bits.js"; +export * from "./count-trailing-zeros.js"; +export * from "./equals.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./multiply.js"; +export * from "./negate.js"; +export * from "./one.js"; +export * from "./reverse-bits.js"; +export * from "./scale.js"; +export * from "./shift-left.js"; +export * from "./shift-right.js"; +export * from "./subtract.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec4/i32/reverse-bits.ts b/packages/ecs/src/interface/math/vec4/i32/reverse-bits.ts new file mode 100644 index 000000000..cdb4649fe --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/reverse-bits.ts @@ -0,0 +1,10 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reverseBits = intrinsic({ wgsl: "reverseBits", parameters: ["vec4i"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32): Vec4_I32 => [ + U32.reverseBits(x) | 0, + U32.reverseBits(y) | 0, + U32.reverseBits(z) | 0, + U32.reverseBits(w) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/scale.ts b/packages/ecs/src/interface/math/vec4/i32/scale.ts new file mode 100644 index 000000000..cd3a45a72 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/scale.ts @@ -0,0 +1,10 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; +import type { I32 } from "../../i32/index.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec4i", "i32"], result: "vec4i" }, ([x, y, z, w]: Vec4_I32, s: I32): Vec4_I32 => [ + Math.imul(x, s), + Math.imul(y, s), + Math.imul(z, s), + Math.imul(w, s), +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/schema.ts b/packages/ecs/src/interface/math/vec4/i32/schema.ts new file mode 100644 index 000000000..cf2736981 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../i32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 4, maxItems: 4 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec4/i32/shift-left.ts b/packages/ecs/src/interface/math/vec4/i32/shift-left.ts new file mode 100644 index 000000000..217e87019 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/shift-left.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; + +export const shiftLeft = ([x, y, z, w]: Vec4_I32, s: number): Vec4_I32 => [x << s, y << s, z << s, w << s]; diff --git a/packages/ecs/src/interface/math/vec4/i32/shift-right.ts b/packages/ecs/src/interface/math/vec4/i32/shift-right.ts new file mode 100644 index 000000000..d30c25431 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/shift-right.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { I32 } from "../../i32/index.js"; + +export const shiftRight = ([x, y, z, w]: Vec4_I32, s: number): Vec4_I32 => [ + I32.shiftRight(x, s), + I32.shiftRight(y, s), + I32.shiftRight(z, s), + I32.shiftRight(w, s), +]; diff --git a/packages/ecs/src/interface/math/vec4/i32/subtract.ts b/packages/ecs/src/interface/math/vec4/i32/subtract.ts new file mode 100644 index 000000000..872952329 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/subtract.ts @@ -0,0 +1,9 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec4i", "vec4i"], result: "vec4i" }, ([x1, y1, z1, w1]: Vec4_I32, [x2, y2, z2, w2]: Vec4_I32): Vec4_I32 => [ + (x1 - x2) | 0, + (y1 - y2) | 0, + (z1 - z2) | 0, + (w1 - w2) | 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/i32/zero.ts b/packages/ecs/src/interface/math/vec4/i32/zero.ts new file mode 100644 index 000000000..f4df86fb9 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/i32/zero.ts @@ -0,0 +1,3 @@ +import type { I32 as Vec4_I32 } from "./i32.js"; + +export const zero: Vec4_I32 = [0, 0, 0, 0]; diff --git a/packages/ecs/src/interface/math/vec4/index.ts b/packages/ecs/src/interface/math/vec4/index.ts new file mode 100644 index 000000000..edd622ba2 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/index.ts @@ -0,0 +1,6 @@ +import type { F32 } from "./f32/f32.js"; + +/** Vec4 is `Vec4.F32`; `Vec4.I32` and `Vec4.U32` hold integer vectors. */ +export type Vec4 = F32; + +export * as Vec4 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/public.ts b/packages/ecs/src/interface/math/vec4/public.ts new file mode 100644 index 000000000..c701852dd --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/public.ts @@ -0,0 +1,4 @@ +export { F32 } from "./f32/f32.js"; +export { I32 } from "./i32/i32.js"; +export { U32 } from "./u32/u32.js"; +export * from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec4/u32/add.ts b/packages/ecs/src/interface/math/vec4/u32/add.ts new file mode 100644 index 000000000..49546e474 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/add.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const add = intrinsic({ wgsl: "+", form: "infix", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + (x1 + x2) >>> 0, + (y1 + y2) >>> 0, + (z1 + z2) >>> 0, + (w1 + w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/bitwise-and.ts b/packages/ecs/src/interface/math/vec4/u32/bitwise-and.ts new file mode 100644 index 000000000..3fccf6991 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/bitwise-and.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseAnd = intrinsic({ wgsl: "&", form: "infix", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + (x1 & x2) >>> 0, + (y1 & y2) >>> 0, + (z1 & z2) >>> 0, + (w1 & w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/bitwise-not.ts b/packages/ecs/src/interface/math/vec4/u32/bitwise-not.ts new file mode 100644 index 000000000..a99407197 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/bitwise-not.ts @@ -0,0 +1,4 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseNot = intrinsic({ wgsl: "~", form: "prefix", parameters: ["vec4u"], result: "vec4u" }, ([x, y, z, w]: Vec4_U32): Vec4_U32 => [(~x) >>> 0, (~y) >>> 0, (~z) >>> 0, (~w) >>> 0]); diff --git a/packages/ecs/src/interface/math/vec4/u32/bitwise-or.ts b/packages/ecs/src/interface/math/vec4/u32/bitwise-or.ts new file mode 100644 index 000000000..9b5d8c001 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/bitwise-or.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseOr = intrinsic({ wgsl: "|", form: "infix", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + (x1 | x2) >>> 0, + (y1 | y2) >>> 0, + (z1 | z2) >>> 0, + (w1 | w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/bitwise-xor.ts b/packages/ecs/src/interface/math/vec4/u32/bitwise-xor.ts new file mode 100644 index 000000000..58b7fa07f --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/bitwise-xor.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const bitwiseXor = intrinsic({ wgsl: "^", form: "infix", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + (x1 ^ x2) >>> 0, + (y1 ^ y2) >>> 0, + (z1 ^ z2) >>> 0, + (w1 ^ w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/clamp.ts b/packages/ecs/src/interface/math/vec4/u32/clamp.ts new file mode 100644 index 000000000..15d2a96b3 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/clamp.ts @@ -0,0 +1,6 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { max } from "./max.js"; +import { min } from "./min.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const clamp = intrinsic({ wgsl: "clamp", parameters: ["vec4u", "vec4u", "vec4u"], result: "vec4u" }, (v: Vec4_U32, minVec: Vec4_U32, maxVec: Vec4_U32): Vec4_U32 => min(max(v, minVec), maxVec)); diff --git a/packages/ecs/src/interface/math/vec4/u32/count-leading-zeros.ts b/packages/ecs/src/interface/math/vec4/u32/count-leading-zeros.ts new file mode 100644 index 000000000..59e8645c5 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/count-leading-zeros.ts @@ -0,0 +1,10 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countLeadingZeros = intrinsic({ wgsl: "countLeadingZeros", parameters: ["vec4u"], result: "vec4u" }, ([x, y, z, w]: Vec4_U32): Vec4_U32 => [ + U32.countLeadingZeros(x), + U32.countLeadingZeros(y), + U32.countLeadingZeros(z), + U32.countLeadingZeros(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/count-one-bits.ts b/packages/ecs/src/interface/math/vec4/u32/count-one-bits.ts new file mode 100644 index 000000000..b096dd379 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/count-one-bits.ts @@ -0,0 +1,10 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countOneBits = intrinsic({ wgsl: "countOneBits", parameters: ["vec4u"], result: "vec4u" }, ([x, y, z, w]: Vec4_U32): Vec4_U32 => [ + U32.countOneBits(x), + U32.countOneBits(y), + U32.countOneBits(z), + U32.countOneBits(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/count-trailing-zeros.ts b/packages/ecs/src/interface/math/vec4/u32/count-trailing-zeros.ts new file mode 100644 index 000000000..960c06516 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/count-trailing-zeros.ts @@ -0,0 +1,10 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const countTrailingZeros = intrinsic({ wgsl: "countTrailingZeros", parameters: ["vec4u"], result: "vec4u" }, ([x, y, z, w]: Vec4_U32): Vec4_U32 => [ + U32.countTrailingZeros(x), + U32.countTrailingZeros(y), + U32.countTrailingZeros(z), + U32.countTrailingZeros(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/equals.ts b/packages/ecs/src/interface/math/vec4/u32/equals.ts new file mode 100644 index 000000000..3af26d3c0 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/equals.ts @@ -0,0 +1,4 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; + +export const equals = (a: Vec4_U32, b: Vec4_U32): boolean => + a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; diff --git a/packages/ecs/src/interface/math/vec4/u32/max.ts b/packages/ecs/src/interface/math/vec4/u32/max.ts new file mode 100644 index 000000000..a5839247d --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/max.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const max = intrinsic({ wgsl: "max", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + Math.max(x1, x2) >>> 0, + Math.max(y1, y2) >>> 0, + Math.max(z1, z2) >>> 0, + Math.max(w1, w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/min.ts b/packages/ecs/src/interface/math/vec4/u32/min.ts new file mode 100644 index 000000000..42ba761d1 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/min.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const min = intrinsic({ wgsl: "min", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + Math.min(x1, x2) >>> 0, + Math.min(y1, y2) >>> 0, + Math.min(z1, z2) >>> 0, + Math.min(w1, w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/multiply.ts b/packages/ecs/src/interface/math/vec4/u32/multiply.ts new file mode 100644 index 000000000..be13ee48e --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/multiply.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const multiply = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + Math.imul(x1, x2) >>> 0, + Math.imul(y1, y2) >>> 0, + Math.imul(z1, z2) >>> 0, + Math.imul(w1, w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/negate.ts b/packages/ecs/src/interface/math/vec4/u32/negate.ts new file mode 100644 index 000000000..82b079796 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/negate.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; + +export const negate = ([x, y, z, w]: Vec4_U32): Vec4_U32 => [(-x) >>> 0, (-y) >>> 0, (-z) >>> 0, (-w) >>> 0]; diff --git a/packages/ecs/src/interface/math/vec4/u32/one.ts b/packages/ecs/src/interface/math/vec4/u32/one.ts new file mode 100644 index 000000000..7d8f5e250 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/one.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; + +export const one: Vec4_U32 = [1, 1, 1, 1]; diff --git a/packages/ecs/src/interface/math/vec4/u32/public.ts b/packages/ecs/src/interface/math/vec4/u32/public.ts new file mode 100644 index 000000000..07fd43ddd --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/public.ts @@ -0,0 +1,22 @@ +export * from "./add.js"; +export * from "./bitwise-and.js"; +export * from "./bitwise-not.js"; +export * from "./bitwise-or.js"; +export * from "./bitwise-xor.js"; +export * from "./clamp.js"; +export * from "./count-leading-zeros.js"; +export * from "./count-one-bits.js"; +export * from "./count-trailing-zeros.js"; +export * from "./equals.js"; +export * from "./max.js"; +export * from "./min.js"; +export * from "./multiply.js"; +export * from "./negate.js"; +export * from "./one.js"; +export * from "./reverse-bits.js"; +export * from "./scale.js"; +export * from "./shift-left.js"; +export * from "./shift-right.js"; +export * from "./subtract.js"; +export * from "./zero.js"; +export * from "./schema.js"; diff --git a/packages/ecs/src/interface/math/vec4/u32/reverse-bits.ts b/packages/ecs/src/interface/math/vec4/u32/reverse-bits.ts new file mode 100644 index 000000000..97c90a2b9 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/reverse-bits.ts @@ -0,0 +1,10 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const reverseBits = intrinsic({ wgsl: "reverseBits", parameters: ["vec4u"], result: "vec4u" }, ([x, y, z, w]: Vec4_U32): Vec4_U32 => [ + U32.reverseBits(x), + U32.reverseBits(y), + U32.reverseBits(z), + U32.reverseBits(w), +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/scale.ts b/packages/ecs/src/interface/math/vec4/u32/scale.ts new file mode 100644 index 000000000..7764a89fa --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/scale.ts @@ -0,0 +1,10 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; +import type { U32 } from "../../u32/index.js"; + +export const scale = intrinsic({ wgsl: "*", form: "infix", parameters: ["vec4u", "u32"], result: "vec4u" }, ([x, y, z, w]: Vec4_U32, s: U32): Vec4_U32 => [ + Math.imul(x, s) >>> 0, + Math.imul(y, s) >>> 0, + Math.imul(z, s) >>> 0, + Math.imul(w, s) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/schema.ts b/packages/ecs/src/interface/math/vec4/u32/schema.ts new file mode 100644 index 000000000..6591fb363 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/schema.ts @@ -0,0 +1,4 @@ +import type { Schema } from "../../../schema/schema.js"; +import { schema as item } from "../../u32/schema.js"; + +export const schema = { type: "array", items: item, minItems: 4, maxItems: 4 } as const satisfies Schema; diff --git a/packages/ecs/src/interface/math/vec4/u32/shift-left.ts b/packages/ecs/src/interface/math/vec4/u32/shift-left.ts new file mode 100644 index 000000000..79b4a7f25 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/shift-left.ts @@ -0,0 +1,8 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; + +export const shiftLeft = ([x, y, z, w]: Vec4_U32, s: number): Vec4_U32 => [ + (x << s) >>> 0, + (y << s) >>> 0, + (z << s) >>> 0, + (w << s) >>> 0, +]; diff --git a/packages/ecs/src/interface/math/vec4/u32/shift-right.ts b/packages/ecs/src/interface/math/vec4/u32/shift-right.ts new file mode 100644 index 000000000..3df3b5540 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/shift-right.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { U32 } from "../../u32/index.js"; + +export const shiftRight = ([x, y, z, w]: Vec4_U32, s: number): Vec4_U32 => [ + U32.shiftRight(x, s), + U32.shiftRight(y, s), + U32.shiftRight(z, s), + U32.shiftRight(w, s), +]; diff --git a/packages/ecs/src/interface/math/vec4/u32/subtract.ts b/packages/ecs/src/interface/math/vec4/u32/subtract.ts new file mode 100644 index 000000000..2dbc45eb4 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/subtract.ts @@ -0,0 +1,9 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; +import { intrinsic } from "../../intrinsic.js"; + +export const subtract = intrinsic({ wgsl: "-", form: "infix", parameters: ["vec4u", "vec4u"], result: "vec4u" }, ([x1, y1, z1, w1]: Vec4_U32, [x2, y2, z2, w2]: Vec4_U32): Vec4_U32 => [ + (x1 - x2) >>> 0, + (y1 - y2) >>> 0, + (z1 - z2) >>> 0, + (w1 - w2) >>> 0, +]); diff --git a/packages/ecs/src/interface/math/vec4/u32/u32.ts b/packages/ecs/src/interface/math/vec4/u32/u32.ts new file mode 100644 index 000000000..e0b60601d --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/u32.ts @@ -0,0 +1,4 @@ +/** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ +export type U32 = readonly [number, number, number, number]; + +export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/u32/zero.ts b/packages/ecs/src/interface/math/vec4/u32/zero.ts new file mode 100644 index 000000000..6e0ba7f5f --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/u32/zero.ts @@ -0,0 +1,3 @@ +import type { U32 as Vec4_U32 } from "./u32.js"; + +export const zero: Vec4_U32 = [0, 0, 0, 0]; diff --git a/packages/ecs/src/interface/platform/platform.ts b/packages/ecs/src/interface/platform/platform.ts new file mode 100644 index 000000000..601d5373c --- /dev/null +++ b/packages/ecs/src/interface/platform/platform.ts @@ -0,0 +1,35 @@ +/** + * The instance type of the global constructor `Name`, or `never` where the loaded libraries don't declare it, so types + * that name platform classes compile with or without the DOM and worker libraries. + */ +export type Instance = + Scope extends { readonly [K in Name]: { readonly prototype: infer P } } ? P : never; + +/** + * A value the platform can move to another thread without copying it, as `postMessage` does with a transfer list. + * `MediaSourceHandle` is left out: the DOM library declares it empty, so every value would match it. + */ +export type Transferable = + | ArrayBuffer + | Instance<"ImageBitmap", Scope> + | Instance<"OffscreenCanvas", Scope> + | Instance<"MessagePort", Scope> + | Instance<"VideoFrame", Scope> + | Instance<"AudioData", Scope> + | Instance<"ReadableStream", Scope> + | Instance<"WritableStream", Scope> + | Instance<"TransformStream", Scope> + | Instance<"RTCDataChannel", Scope>; + +/** Whether every value of `T` but `null` and `undefined` is Transferable. */ +export type IsTransferable = + [Exclude] extends [never] ? false : + [Exclude] extends [Transferable] ? true : + false; + +/** Whether `T`, but `null` and `undefined`, is a DOM `Node`: never where the DOM library isn't loaded. */ +export type IsNode = + [Instance<"Node", Scope>] extends [never] ? false : + [Exclude] extends [never] ? false : + [Exclude] extends [Instance<"Node", Scope>] ? true : + false; diff --git a/packages/ecs/src/interface/registry/archetypes.ts b/packages/ecs/src/interface/registry/archetypes.ts new file mode 100644 index 000000000..4beff6eb2 --- /dev/null +++ b/packages/ecs/src/interface/registry/archetypes.ts @@ -0,0 +1,23 @@ +import { labelOf } from './names.js'; + +/** Where an archetype holds its program-wide index: a property no other code names. */ +const indexKey = Symbol("archetypeIndex"); + +let next = 0; + +/** + * Gives a list of components a program-wide index, once, after checking it names no component twice. Throws for a + * component listed twice. + */ +export const indexArchetype = (components: readonly unknown[]): void => { + for (let k = 1; k < components.length; k++) { + if (components.indexOf(components[k]) < k) throw new Error(`An archetype lists "${labelOf(components[k])}" twice.`); + } + if (!Reflect.has(components, indexKey)) Object.defineProperty(components, indexKey, { value: next++ }); +}; + +/** The program-wide index `Archetype.create` gave a list, or -1 for a list it wasn't given. */ +export const archetypeIndexOf = (components: readonly unknown[]): number => { + const index: unknown = Reflect.get(components, indexKey); + return typeof index === "number" ? index : -1; +}; diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts new file mode 100644 index 000000000..689d66b6e --- /dev/null +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -0,0 +1,102 @@ +import type { Schema } from '../schema/schema.js'; +import type { Key as TraitKey } from '../trait/key.js'; +import { layoutOf } from './layout.js'; +import { claimKey, declarationOf, failRegistration, firstKeyOf, kindOf, labelOf, requireKey, sameDeclaration } from './names.js'; +import { isReduction } from './reduction.js'; +import { allowsNull, hasBoolean, isEntity, isString, isStruct } from './schemas.js'; +import { isTrait } from './traits.js'; + +/** + * A registered declaration, as any consumer iterating components or resources sees it: its `type` and other traits. + * Without `type`, the value isn't data. A resource's `default`, or a derived object's, is what it starts at, when its + * schema has none. + */ +export type AnyDeclaration = { readonly type?: Schema; readonly default?: unknown } & { readonly [T in TraitKey]?: unknown }; + +/** The value schema of any registered component or resource, or undefined for a value that isn't data. */ +export const schemaOf = (key: unknown): Schema | undefined => declarationOf(key)?.type; + +/** Global constructors of Transferable values, where the platform has them. */ +const transferables: readonly string[] = [ + "ArrayBuffer", "ImageBitmap", "OffscreenCanvas", "MessagePort", "VideoFrame", "AudioData", + "ReadableStream", "WritableStream", "TransformStream", "RTCDataChannel", +]; + +/** Whether `value` is an instance of the global constructor `name`, where the platform has one. */ +const isInstance = (value: unknown, name: string): boolean => { + const constructor: unknown = Reflect.get(globalThis, name); + return typeof constructor === "function" && value instanceof constructor; +}; + +/** + * The thread a registered declaration's value lives on: none for a struct or a string, which live in shared memory, + * else its `thread` or `"world"`. Only a value with a struct or string schema is known to be one. + */ +export const threadOfDeclaration = (declaration: AnyDeclaration): string | null => { + if (declaration.type !== undefined && (isStruct(declaration.type) || isString(declaration.type))) return null; + return typeof declaration.thread === "string" ? declaration.thread : "world"; +}; + +/** Throws for a declaration whose traits can't hold, so far as values tell: types check the rest. */ +const check = (kind: "component" | "resource", key: symbol, declaration: AnyDeclaration): void => { + const fail = (message: string): never => failRegistration(kind, key, message); + for (const trait of Reflect.ownKeys(declaration)) { + if (trait !== "default" && !isTrait(trait)) fail(`${labelOf(trait)} isn't a registered trait.`); + } + const { type } = declaration; + if (type !== undefined && "default" in declaration && (kind === "component" || "default" in type)) { + fail(kind === "component" ? "its schema holds its default." : "its default is given twice, by its schema and its declaration."); + } + try { + layoutOf(type); + } + catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + if (declaration.reference !== undefined && !(type !== undefined && isEntity(type))) fail("a reference's type is Entity.schema."); + // A cycle of `within` hosts closes when its last component registers, so it's found then. + const cycle: symbol[] = [key]; + for (let host = declaration.within; typeof host === "symbol"; host = declarationOf(host)?.within) { + const first = firstKeyOf(host); + if (first === firstKeyOf(key)) fail(`components are within each other in a cycle: ${cycle.map(labelOf).sort().join(", ")}.`); + if (cycle.includes(first)) break; + cycle.push(first); + } + if (declaration.derived !== undefined && kind !== "component") fail("only a component is derived."); + if (declaration.derived === true && type !== undefined && !("default" in type || "const" in type || allowsNull(type))) { + fail("a derived component's type has a default, which it starts at."); + } + if (declaration.reduce !== undefined) { + const operator = declaration.reduce; + if (kind !== "resource") fail("only a resource is reduced."); + if (!isReduction(operator)) fail(`reduce is one of "+", "*", "min", "max", "&", "|" or "^".`); + if (type === undefined || !isStruct(type)) fail("a reduced resource is a struct of numbers or booleans."); + if (type !== undefined && hasBoolean(type) && operator !== "&" && operator !== "|" && operator !== "^") fail(`booleans reduce with "&", "|" or "^".`); + } + const { thread } = declaration; + if (thread !== undefined) { + if (typeof thread !== "string" || thread === "") fail("thread is a thread's name."); + if (type !== undefined && isStruct(type)) fail("a struct lives in shared memory, so it has no thread."); + if (type !== undefined && isString(type)) fail("a string lives in shared memory, so it has no thread."); + } + if (isInstance(declaration.default, "Node") && thread !== "main") fail(`a DOM Node lives on "main".`); + const { transfer } = declaration; + if (transfer !== undefined) { + if (transfer !== true) fail("transfer is true."); + if (type !== undefined) fail("a value with a schema is data, which isn't Transferable."); + const value = declaration.default; + if (value !== undefined && value !== null && !transferables.some(constructor => isInstance(value, constructor))) fail("its value isn't Transferable."); + } +}; + +/** + * Registers a declaration of `kind` under `key`, fixed from then on. The same declaration again is accepted, and so is + * a new symbol with the id of a key of this kind, as a module evaluated again makes it. + */ +export const createRegistry = (kind: "component" | "resource") => (key: unknown, declaration: AnyDeclaration): void => { + const symbol = requireKey(kind, key); + const existing = kindOf(symbol); + if (existing === kind && sameDeclaration(declarationOf(symbol), declaration)) return; + if (existing === undefined) check(kind, symbol, declaration); + claimKey(symbol, kind, declaration); +}; diff --git a/packages/ecs/src/interface/registry/finalizer-order.ts b/packages/ecs/src/interface/registry/finalizer-order.ts new file mode 100644 index 000000000..9a0596fdb --- /dev/null +++ b/packages/ecs/src/interface/registry/finalizer-order.ts @@ -0,0 +1,52 @@ +import type { Finalizer } from '../finalizer/created.js'; +import { firstItemOf, labelOfItem } from './items.js'; +import { firstKeyOf, labelOf } from './names.js'; + +/** + * Finalizers in run order: `before` and `after` first, then registration order. Throws for a cycle, and for two that + * both write a resource, or where one reads what the other writes, with no ordering between them. + */ +export const orderFinalizers = (runners: readonly Finalizer[]): Finalizer[] => { + const indexOf = new Map(runners.map((runner, index) => [firstItemOf(runner), index])); + const successors = runners.map(() => new Set()); + runners.forEach((runner, index) => { + const { before, after } = runner.declaration; + for (const finalizer of before ?? []) { const other = indexOf.get(firstItemOf(finalizer)); if (other !== undefined) successors[index].add(other); } + for (const finalizer of after ?? []) { const other = indexOf.get(firstItemOf(finalizer)); if (other !== undefined) successors[other].add(index); } + }); + const incoming = runners.map(() => 0); + for (const next of successors) for (const other of next) incoming[other]++; + const order: number[] = []; + const ready = runners.flatMap((_, index) => incoming[index] === 0 ? [index] : []); + while (ready.length > 0) { + ready.sort((a, b) => a - b); + const index = ready.shift() ?? 0; + order.push(index); + for (const other of successors[index]) if (--incoming[other] === 0) ready.push(other); + } + if (order.length < runners.length) { + const cycle = runners.filter((_, index) => !order.includes(index)).map(runner => `"${labelOfItem(runner)}"`).join(", "); + throw new Error(`Finalizers ${cycle} are ordered in a cycle.`); + } + const reaches = (from: number, to: number): boolean => { + const seen = new Set(); + const stack = [from]; + while (stack.length > 0) { + const next = stack.pop() ?? 0; + if (next === to) return true; + if (seen.has(next)) continue; + seen.add(next); + stack.push(...successors[next]); + } + return false; + }; + const writes = runners.map(runner => new Set((runner.declaration.resources?.write ?? []).map(firstKeyOf))); + const reads = runners.map(runner => new Set((runner.declaration.resources?.read ?? []).map(firstKeyOf))); + for (let a = 0; a < runners.length; a++) for (let b = a + 1; b < runners.length; b++) { + const conflict = [...writes[a]].find(key => writes[b].has(key) || reads[b].has(key)) ?? [...writes[b]].find(key => reads[a].has(key)); + if (conflict !== undefined && !reaches(a, b) && !reaches(b, a)) { + throw new Error(`Finalizers "${labelOfItem(runners[a])}" and "${labelOfItem(runners[b])}" both access "${labelOf(conflict)}", one writing it; order them with before or after.`); + } + } + return order.map(index => runners[index]); +}; diff --git a/packages/ecs/src/interface/registry/ids.test-helper.ts b/packages/ecs/src/interface/registry/ids.test-helper.ts new file mode 100644 index 000000000..cea8bd1bb --- /dev/null +++ b/packages/ecs/src/interface/registry/ids.test-helper.ts @@ -0,0 +1,14 @@ +import { idsName } from './ids.js'; + +/** + * Gives a key or a declaration an id, as code the plugin generates does for a top-level `Symbol(...)` declaration or a + * top-level binding's constructor call. Returns what it was given. + */ +export const identify = (value: T, id: string): T => { + const name = Symbol.for(idsName); + const existing: unknown = Reflect.get(globalThis, name); + const map = existing instanceof Map ? existing : new Map(); + map.set(value, id); + Reflect.set(globalThis, name, map); + return value; +}; diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts new file mode 100644 index 000000000..c68f58252 --- /dev/null +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../component/component.js"; +import { Finalizer } from "../finalizer/finalizer.js"; +import { Resource } from "../resource/resource.js"; +import { Rule } from "../rule/rule.js"; +import { System } from "../system/system.js"; +import { Trait } from "../trait/trait.js"; +import { identify } from "./ids.test-helper.js"; +import { declarationOf, kindOf, labelOf, programFinalizers, programRules, requireDefinition, sameItem, scheduleOf } from "./index.js"; + +// Each pair is one module's key and the key that module makes when evaluated again: one id, two symbols. +const score = Symbol("score"); +const scoreAgain = Symbol("score"); +const shown = Symbol("shown"); +const shownAgain = Symbol("shown"); +const level = Symbol("level"); +const levelAgain = Symbol("level"); +const changed = Symbol("changed"); +const changedAgain = Symbol("changed"); +const crossed = Symbol("crossed"); +const crossedAgain = Symbol("crossed"); +const unnamed = Symbol("score"); +identify(unnamed, "idsTest/other.ts#score"); +const tagged = Symbol("tagged"); +const taggedAgain = Symbol("tagged"); + +identify(score, "idsTest/keys.ts#score"); +identify(scoreAgain, "idsTest/keys.ts#score"); +identify(shown, "idsTest/keys.ts#shown"); +identify(shownAgain, "idsTest/keys.ts#shown"); +identify(level, "idsTest/keys.ts#level"); +identify(levelAgain, "idsTest/keys.ts#level"); +identify(changed, "idsTest/keys.ts#changed"); +identify(changedAgain, "idsTest/keys.ts#changed"); +identify(crossed, "idsTest/keys.ts#crossed"); +identify(crossedAgain, "idsTest/keys.ts#crossed"); +identify(tagged, "idsTest/keys.ts#tagged"); +identify(taggedAgain, "idsTest/keys.ts#tagged"); + +declare module "@adobe/ecs" { + export interface Components { + [score]: { type: number }; + [scoreAgain]: { type: number }; + [shown]: { type: true; derived: true }; + [shownAgain]: { type: true; derived: true }; + [changed]: { type: number }; + [changedAgain]: { type: string }; + [crossed]: { type: number }; + [unnamed]: { type: number }; + } + export interface Resources { + [level]: { type: number }; + [levelAgain]: { type: number }; + [crossedAgain]: { type: number }; + } + export interface Traits { + readonly [tagged]: true; + readonly [taggedAgain]: true; + } +} + +Component.register(score, { type: { type: "number" } }); +Component.register(shown, { type: { const: true }, derived: true }); +Resource.register(level, { type: { type: "number", default: 0 } }); +Component.register(changed, { type: { type: "number" } }); +Component.register(crossed, { type: { type: "number" } }); +Trait.register(tagged); + +describe("keys with ids", () => { + it("labels a key by its description, and by its id once another registered key has its description", () => { + expect(labelOf(score)).toBe("score"); + Component.register(unnamed, { type: { type: "number" } }); + expect([labelOf(score), labelOf(unnamed)]).toEqual(["idsTest/keys.ts#score", "idsTest/other.ts#score"]); + expect(labelOf(Symbol("score"))).toBe("score"); + }); + it("accepts another symbol under a registered key's id with the same declaration, as the same key", () => { + Component.register(scoreAgain, { type: { type: "number" } }); + Component.register(shownAgain, { type: { const: true }, derived: true }); + Resource.register(levelAgain, { type: { type: "number", default: 0 } }); + Trait.register(taggedAgain); + expect([kindOf(scoreAgain), kindOf(levelAgain), kindOf(taggedAgain)]).toEqual(["component", "resource", "trait"]); + expect(declarationOf(scoreAgain)).toBe(declarationOf(score)); + expect(labelOf(scoreAgain)).toBe("idsTest/keys.ts#score"); + }); + it("rejects another symbol under a registered key's id with another declaration, or as another kind", () => { + expect(() => Component.register(changedAgain, { type: { type: "string" } })).toThrow( + `Cannot register component "idsTest/keys.ts#changed": its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); + expect(() => Resource.register(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( + `Cannot register resource "idsTest/keys.ts#crossed": its id is a component's.`); + expect([kindOf(changedAgain), kindOf(crossedAgain)]).toEqual([undefined, undefined]); + }); + it("keeps keys of different ids apart, though their descriptions match", () => { + Component.register(unnamed, { type: { type: "number" } }); + System.create(identify({ name: "idsTest/unnamed", components: { write: [score] } }, "idsTest/systems.ts#unnamed"), row => { row[score] = 1; }); + expect(() => System.create(identify({ name: "idsTest/unnamed", components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), row => { row[unnamed] = 1; })).toThrow("reload the page"); + }); +}); + +describe("declarations evaluated again", () => { + it("accept a system whose keys match by id, as a new system", () => { + const first = System.create(identify({ name: "idsTest/system", components: { write: [score] }, resources: { read: [level] } }, "idsTest/systems.ts#system"), (row, { resources }) => { row[score] = resources[level]; }); + const again = System.create(identify({ name: "idsTest/system", components: { write: [scoreAgain] }, resources: { read: [levelAgain] } }, "idsTest/systems.ts#system"), (row, { resources }) => { row[scoreAgain] = resources[levelAgain]; }); + expect(again).not.toBe(first); + expect(sameItem(again, first)).toBe(true); + }); + it("accept a rule whose keys match by id, which is already recorded", () => { + const rule = Rule.create(identify({ name: "idsTest/rule", components: { has: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); + const rules = programRules().length; + expect(Rule.create(identify({ name: "idsTest/rule", components: { has: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(rule); + expect(programRules()).toHaveLength(rules); + }); + it("accept a finalizer whose keys match by id, keeping its place with the new body", () => { + const first = Finalizer.create(identify({ name: "idsTest/finalizer", components: { read: [score] } }, "idsTest/systems.ts#finalizer"), () => {}); + const body = (): void => {}; + expect(Finalizer.create(identify({ name: "idsTest/finalizer", components: { read: [scoreAgain] } }, "idsTest/systems.ts#finalizer"), body)).toBe(first); + const found = programFinalizers().find(finalizer => finalizer.name === "idsTest/finalizer"); + expect(found?.kind === "finalizer" ? found.body : null).toBe(body); + }); + it("schedule symbols of one id as one key", () => { + const write = System.create({ name: "idsTest/write", components: { write: [score] } }, row => { row[score] = 1; }); + const read = System.create({ name: "idsTest/read", components: { read: [scoreAgain] } }, row => { void row[scoreAgain]; }); + expect(() => scheduleOf(requireDefinition(System.create({ name: "idsTest/unordered", systems: [write, read] })))).toThrow("both access"); + }); +}); diff --git a/packages/ecs/src/interface/registry/ids.ts b/packages/ecs/src/interface/registry/ids.ts new file mode 100644 index 000000000..d6ad0dd0a --- /dev/null +++ b/packages/ecs/src/interface/registry/ids.ts @@ -0,0 +1,30 @@ +/** + * The `Symbol.for` name of the global `Map` that code the plugin generates fills: from key to id, and from a + * system's, rule's or finalizer's declaration to id. Its version changes with the map's format. + */ +export const idsName = "@adobe/ecs/ids@1"; + +const ids = Symbol.for(idsName); + +const idsMap = (): Map | undefined => { + const map: unknown = Reflect.get(globalThis, ids); + return map instanceof Map ? map : undefined; +}; + +/** The id the plugin gave a key, its module path and binding (`src/keys.ts#position`), if it gave one. */ +export const idOfKey = (key: symbol): string | undefined => { + const id: unknown = idsMap()?.get(key); + return typeof id === "string" ? id : undefined; +}; + +/** + * The id the plugin gave the declaration a top-level binding's constructor call passes (`src/systems.ts#move`), if + * it gave one, taken out of the map so the object declares nothing else under it. + */ +export const takeIdOfDeclaration = (declaration: object): string | undefined => { + const map = idsMap(); + const id: unknown = map?.get(declaration); + if (id === undefined) return undefined; + map?.delete(declaration); + return typeof id === "string" ? id : undefined; +}; diff --git a/packages/ecs/src/interface/registry/index.ts b/packages/ecs/src/interface/registry/index.ts new file mode 100644 index 000000000..51cf087ee --- /dev/null +++ b/packages/ecs/src/interface/registry/index.ts @@ -0,0 +1,29 @@ +/** What registration recorded, for the implementation to read. Not part of the public interface. */ +export type { AnyDeclaration } from './declarations.js'; +export { schemaOf, threadOfDeclaration } from './declarations.js'; +export type { Kind } from './names.js'; +export { declarationOf, firstKeyOf, keyOfId, kindOf, labelOf, newestKeyOf } from './names.js'; +export type { Item } from './program.js'; +export { definitionOf, programFinalizers, programRules, programVersion, requireDefinition } from './program.js'; +export type { Schedule } from './schedule.js'; +export { accessOf, isLeaf, isObserver, reducedBy, scheduleOf } from './schedule.js'; +export type { RuleFacts, Strata } from './strata.js'; +export { strataOf } from './strata.js'; +export type { Placed } from './threads.js'; +export { threadOf } from './threads.js'; +export { isTrait } from './traits.js'; +export { idOfKey } from './ids.js'; +export { isWorldWritten } from './written.js'; +export { sameDeclaration } from './names.js'; +export { firstItemOf, identifiedItems, idOfItem, itemOfId, labelOfItem, sameItem } from './items.js'; +export { allowsNull, conditional, isStruct } from './schemas.js'; +export { isReduction, reductionOperators } from './reduction.js'; +export type { Definition } from '../system/definition.js'; +export type { Leaf } from '../system/leaf.js'; +export type { Composite } from '../system/composite.js'; +export type { Rule } from '../rule/created.js'; +export type { Finalizer } from '../finalizer/created.js'; +export { archetypeIndexOf } from './archetypes.js'; +export { indexDefinitionOf, indexOfLookup, lookupKeysOf, orderKeysOf, watchedBy, watchesEntries } from './indexes.js'; +export type { Created as IndexDefinition } from '../indexes/created.js'; +export type { Declaration as IndexDeclaration } from '../indexes/declaration.js'; diff --git a/packages/ecs/src/interface/registry/indexes.ts b/packages/ecs/src/interface/registry/indexes.ts new file mode 100644 index 000000000..24e7de2ff --- /dev/null +++ b/packages/ecs/src/interface/registry/indexes.ts @@ -0,0 +1,73 @@ +import type { Created } from '../indexes/created.js'; +import { labelOfItem, recordItem, registeredAs } from './items.js'; +import { firstKeyOf, labelOf, sameDeclaration } from './names.js'; + +/** + * Records an index under the id the plugin gave its declaration and returns it; declared again under that id the same, + * as a module evaluated again declares it, it's the index already recorded. Throws for that id with another kind or + * declaration. + */ +export const addIndex = (index: Created, id: string | undefined): Created => { + const existing = registeredAs(index, id, sameDeclaration); + const found = existing === undefined ? undefined : indexDefinitionOf(existing); + if (found !== undefined) return found; + recordItem(index, id, undefined); + return index; +}; + +/** Only `Index.create` makes values of kind `"index"` with a declaration. */ +const isIndex = (value: object): value is Created => + "kind" in value && value.kind === "index" && "declaration" in value && typeof value.declaration === "object" && value.declaration !== null; + +/** What `Index.create` recorded for a value, or undefined for any other value. */ +export const indexDefinitionOf = (value: unknown): Created | undefined => typeof value === "object" && value !== null && isIndex(value) ? value : undefined; + +type Key = symbol; + +/** A lookup as a declaration holds it: by entity, or through an index. */ +type AnyLookup = { readonly index?: unknown; readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly changed?: readonly Key[] | true }; + +const noKeys: readonly never[] = []; + +/** The components whose changes a lookup's rows or lists report: none for an index lookup's `changed: true`. */ +export const watchedBy = (lookup: { readonly changed?: readonly K[] | true }): readonly K[] => lookup.changed === true ? noKeys : lookup.changed ?? noKeys; + +/** Whether a lookup's `changed` is true: its index's entries changing is a change filter of its system. */ +export const watchesEntries = (lookup: AnyLookup): boolean => lookup.changed === true; + +/** The index a lookup goes through, or undefined for a lookup by entity. */ +export const indexOfLookup = (lookup: AnyLookup): Created | undefined => lookup.index === undefined ? undefined : indexDefinitionOf(lookup.index); + +/** The key parts a lookup reads to find rows: its index's, or none for a lookup by entity. */ +export const lookupKeysOf = (lookup: AnyLookup): readonly Key[] => indexOfLookup(lookup)?.declaration.key ?? []; + +/** + * Throws for a lookup by entity whose `changed` names a component it neither reads nor has, for an index lookup whose + * `index` isn't a value `Index.create` returned, or whose `read` or `changed` names a component that not every entity + * the index holds has, and for a `changed: true` but on a system's index lookup. + */ +export const checkLookup = (what: "System" | "Finalizer", name: string, lookupName: string, lookup: AnyLookup): void => { + if (lookup.changed === true) { + if (what === "Finalizer") throw new Error(`Finalizer "${name}": lookup "${lookupName}"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); + if (lookup.index === undefined) throw new Error(`System "${name}": lookup "${lookupName}"'s changed is true, which only an index lookup takes; name the components it watches.`); + } + if (lookup.index === undefined) { + for (const key of watchedBy(lookup)) { + if (!(lookup.read ?? []).includes(key) && !(lookup.has ?? []).includes(key)) throw new Error(`${what} "${name}": lookup "${lookupName}" watches "${labelOf(key)}", which it neither reads nor has; name it in its read or has.`); + } + return; + } + const index = indexDefinitionOf(lookup.index); + if (index === undefined) throw new Error(`${what} "${name}": lookup "${lookupName}" goes through a value Index.create didn't return.`); + const held = new Set([...index.declaration.key, ...index.declaration.components?.has ?? []].map(firstKeyOf)); + const check = (verb: string, keys: readonly Key[] | undefined): void => { + for (const key of keys ?? []) { + if (!held.has(firstKeyOf(key))) throw new Error(`${what} "${name}": lookup "${lookupName}" ${verb} "${labelOf(key)}", which not every entity index "${labelOfItem(index)}" holds has; name its key parts or its has.`); + } + }; + check("reads", lookup.read); + check("watches", watchedBy(lookup)); +}; + +/** The key parts a system's rows are ordered by: its `order` index's, or none. */ +export const orderKeysOf = (declaration: { readonly order?: unknown }): readonly Key[] => indexDefinitionOf(declaration.order)?.declaration.key ?? []; diff --git a/packages/ecs/src/interface/registry/items.ts b/packages/ecs/src/interface/registry/items.ts new file mode 100644 index 000000000..187345807 --- /dev/null +++ b/packages/ecs/src/interface/registry/items.ts @@ -0,0 +1,125 @@ +/** A registered system, rule or finalizer: what each constructor records, told apart by `kind`. */ +type Registered = { readonly kind: string; readonly name: string; readonly declaration: object }; + +/** + * A registered item: its kind and declaration, those of the first value registered under its id, its name, its id if + * the plugin gave one, and the label it has without one, numbered when another item had its name; shared by every + * value registered under its id. `first` is the value registered first, which stands for it wherever items compare. + */ +type Entry = { + readonly kind: string; + readonly declaration: object; + readonly name: string; + readonly id: string | undefined; + readonly numbered: string; + readonly first: Registered; +}; + +/** Every system, rule and finalizer, by value: values of one id share an entry. */ +const entries = new WeakMap(); + +/** Entries by the ids the plugin gave their declarations. */ +const identified = new Map(); + +/** Numbered labels given, so each item's is unique. */ +const labels = new Set(); + +/** How many registered items have each name. */ +const named = new Map(); + +/** Whether two values are one item: the same value, or values registered under one id. */ +export const sameItem = (a: object, b: object): boolean => { + if (a === b) return true; + const entry = entries.get(a); + return entry !== undefined && entry === entries.get(b); +}; + +/** The value first registered as this item, which stands for it wherever items compare; any other value as it is. */ +export const firstItemOf = (item: object): object => entries.get(item)?.first ?? item; + +/** + * How errors and reports name a system, rule or finalizer: its name while no other registered item has it, else its + * name with its id when the plugin gave one, and without one its name numbered by registration order. An item not + * registered is named by its name. + */ +export const labelOfItem = (item: { readonly name: string }): string => { + const entry = entries.get(item); + if (entry === undefined || (named.get(entry.name) ?? 0) <= 1) return item.name; + return entry.id === undefined ? entry.numbered : `${entry.name} (${entry.id})`; +}; + +/** + * The item already registered under `id` that `item` is: one of its kind with the same declaration, as a module + * evaluated again declares it; undefined without an id or for an id not registered. Throws for an id registered as + * another kind or with another declaration. `same` compares declarations. + */ +export const registeredAs = (item: T, id: string | undefined, same: (a: unknown, b: unknown) => boolean): object | undefined => { + if (id === undefined) return undefined; + const entry = identified.get(id); + if (entry === undefined) return undefined; + if (entry.kind !== item.kind) throw new Error(`Cannot declare "${item.name}" (${id}): its id is a ${entry.kind}'s.`); + if (!same(entry.declaration, item.declaration)) { + throw new Error(`Cannot declare "${item.name}" (${id}): its id is another item's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); + } + return entry.first; +}; + +/** A label for an item registered now without an id, its name numbered when another item's has it. */ +const numberedFor = (name: string): string => { + let label = name; + for (let n = 2; labels.has(label); n++) label = `${name}#${n}`; + labels.add(label); + return label; +}; + +/** An item without an id is forgotten once collected: its name's count and numbered label go with it. */ +const forgotten = new FinalizationRegistry<{ readonly name: string; readonly numbered: string }>(({ name, numbered }) => { + labels.delete(numbered); + const count = (named.get(name) ?? 0) - 1; + if (count > 0) named.set(name, count); + else named.delete(name); +}); + +/** + * Records an item under its id: a value of an item `registeredAs` found shares its entry, and any other is a new item. + */ +export const recordItem = (item: Registered, id: string | undefined, existing: object | undefined): void => { + const shared = existing === undefined ? undefined : entries.get(existing); + if (shared !== undefined) { + entries.set(item, shared); + return; + } + const entry: Entry = { kind: item.kind, declaration: item.declaration, name: item.name, id, numbered: numberedFor(item.name), first: item }; + named.set(item.name, (named.get(item.name) ?? 0) + 1); + if (id !== undefined) identified.set(id, entry); + else forgotten.register(item, { name: item.name, numbered: entry.numbered }); + entries.set(item, entry); +}; + +/** How many items of each kind were declared without a name or an id, to number their labels. */ +const anonymous = new Map(); + +/** + * An item's name: the one its declaration gives; without one, its binding with the id the plugin gave it + * (`spawn (src/setup.ts#spawn)`), and without an id, its kind numbered by creation (`system #3`). + */ +export const nameOf = (name: string | undefined, id: string | undefined, kind: "system" | "rule" | "finalizer" | "index"): string => { + if (name !== undefined) return name; + if (id !== undefined) return `${id.slice(id.lastIndexOf("#") + 1)} (${id})`; + const count = (anonymous.get(kind) ?? 0) + 1; + anonymous.set(kind, count); + return `${kind} #${count}`; +}; + +/** The id the plugin gave a system, rule or finalizer, by which other threads name it, if it gave one. */ +export const idOfItem = (item: object): string | undefined => entries.get(item)?.id; + +/** The item first registered under an id, or undefined where no module declaring it was imported. */ +export const itemOfId = (id: string): { readonly kind: string; readonly name: string } | undefined => identified.get(id)?.first; + +/** The ids of every system, rule and finalizer of `kind` registered with one, as a joined thread tells the world what it imported. */ +export const identifiedItems = (kind: string): readonly string[] => { + const found: string[] = []; + for (const [id, entry] of identified) if (entry.kind === kind) found.push(id); + return found; +}; diff --git a/packages/ecs/src/interface/registry/layout.test.ts b/packages/ecs/src/interface/registry/layout.test.ts new file mode 100644 index 000000000..9f19f3dd4 --- /dev/null +++ b/packages/ecs/src/interface/registry/layout.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import type { Schema } from "../schema/schema.js"; +import { threadOfDeclaration } from "./declarations.js"; +import { layoutOf } from "./layout.js"; +import { allowsNull, conditional, hasBoolean, isStruct } from "./schemas.js"; + +const f32: Schema = { type: "number", precision: 1 }; +const vec = (length: number, layout?: "storage" | "vertex"): Schema => ({ type: "array", items: f32, minItems: length, maxItems: length, ...layout === undefined ? {} : { layout } }); + +/** Byte offsets of each leaf, and the element stride. */ +const placement = (schema: Schema): { readonly stride: number; readonly offsets: readonly number[] } => { + const layout = layoutOf(schema); + if (layout.kind !== "linear") throw new Error("not linear"); + return { stride: layout.stride, offsets: layout.leaves.map(leaf => leaf.offset) }; +}; + +describe("layoutOf", () => { + it("follows WGSL storage rules: vec3 aligned to 16 but 12 bytes, so a scalar packs after it", () => { + expect(placement(vec(3, "storage"))).toEqual({ stride: 16, offsets: [0, 4, 8] }); + expect(placement(vec(2, "storage"))).toEqual({ stride: 8, offsets: [0, 4] }); + expect(placement({ type: "object", layout: "storage", properties: { position: vec(3), mass: f32 } })).toEqual({ stride: 16, offsets: [0, 4, 8, 12] }); + expect(placement({ type: "object", layout: "storage", properties: { mass: f32, position: vec(3) } })).toEqual({ stride: 32, offsets: [0, 16, 20, 24] }); + // array, 2>: elements at a 16-byte stride. + expect(placement({ type: "array", layout: "storage", items: vec(3), minItems: 2, maxItems: 2 })).toEqual({ stride: 32, offsets: [0, 4, 8, 16, 20, 24] }); + }); + + it("packs vertex layouts tightly at 4 bytes", () => { + expect(placement(vec(3, "vertex"))).toEqual({ stride: 12, offsets: [0, 4, 8] }); + expect(placement({ type: "object", layout: "vertex", properties: { mass: f32, position: vec(3), flag: { type: "boolean" } } })).toEqual({ stride: 20, offsets: [0, 4, 8, 12, 16] }); + }); + + it("packs CPU layouts tightly with natural alignment", () => { + expect(placement(vec(3))).toEqual({ stride: 12, offsets: [0, 4, 8] }); + expect(placement({ type: "object", properties: { flag: { type: "boolean" }, weight: { type: "number" }, mass: f32 } })).toEqual({ stride: 24, offsets: [0, 8, 16] }); + }); + + it("stores sets and maps as objects, even of numbers, and nothing in them as a struct", () => { + const set: Schema = { type: "set", items: { type: "number" } }; + const map: Schema = { type: "map", keys: { type: "integer", minimum: 0, maximum: 9 }, values: { type: "boolean" } }; + expect([layoutOf(set).kind, layoutOf(map).kind]).toEqual(["object", "object"]); + expect(layoutOf({ type: "object", properties: { at: f32, marks: set } }).kind).toBe("object"); + expect([isStruct(set), isStruct(map), hasBoolean(map), allowsNull(set), conditional(map)]).toEqual([false, false, false, false, false]); + expect(threadOfDeclaration({ type: map })).toBe("world"); + expect(() => layoutOf({ type: "array", layout: "storage", items: set, minItems: 2, maxItems: 2 })).toThrow("storage layout needs"); + }); + + it("rejects GPU layouts the GPU can't share, naming the leaf", () => { + expect(() => layoutOf({ type: "object", layout: "storage", properties: { mass: { type: "number" } } })).toThrow('["mass"] is f64'); + expect(() => layoutOf({ type: "array", layout: "vertex", items: { type: "string" }, minItems: 2, maxItems: 2 })).toThrow("vertex layout needs"); + }); + + it("reads and writes interleaved values, leaving padding untouched", () => { + const layout = layoutOf(vec(3, "storage")); + if (layout.kind !== "linear") throw new Error("not linear"); + const buffer = new ArrayBuffer(64); + const views = { f32: new Float32Array(buffer), f64: new Float64Array(buffer), i32: new Int32Array(buffer), u32: new Uint32Array(buffer), u8: new Uint8Array(buffer) }; + views.f32.fill(-1); + layout.write(views, 1, [1, 2, 3]); + expect(layout.read(views, 1)).toEqual([1, 2, 3]); + expect([...views.f32.subarray(0, 8)]).toEqual([-1, -1, -1, -1, 1, 2, 3, -1]); + }); + + it("reads and writes structs of every leaf kind, nested and with any member name", () => { + const integer: Schema = { type: "integer", minimum: 0, maximum: 255 }; + const layout = layoutOf({ + type: "object", + properties: { + ["__proto__"]: f32, + flag: { type: "boolean" }, + count: integer, + at: vec(2), + inner: { type: "object", properties: { x: { type: "number" }, pairs: { type: "array", items: vec(2), minItems: 2, maxItems: 2 } } }, + }, + }); + if (layout.kind !== "linear") throw new Error("not linear"); + const buffer = new ArrayBuffer(256); + const views = { f32: new Float32Array(buffer), f64: new Float64Array(buffer), i32: new Int32Array(buffer), u32: new Uint32Array(buffer), u8: new Uint8Array(buffer) }; + const value = JSON.parse('{ "__proto__": 0.5, "flag": true, "count": 7, "at": [1, 2], "inner": { "x": 0.25, "pairs": [[3, 4], [5, 6]] } }'); + layout.write(views, 1, value); + const read = layout.read(views, 1); + expect(read).toEqual(value); + expect(Object.getPrototypeOf(read)).toBe(Object.prototype); + layout.write(views, 1, { flag: false, count: 1, at: [0, 0] }); + expect(layout.read(views, 1)).toEqual(JSON.parse('{ "__proto__": null, "flag": false, "count": 1, "at": [0, 0], "inner": { "x": null, "pairs": [[null, null], [null, null]] } }', (_, leaf: unknown) => leaf === null ? NaN : leaf)); + }); +}); diff --git a/packages/ecs/src/interface/registry/layout.ts b/packages/ecs/src/interface/registry/layout.ts new file mode 100644 index 000000000..8ceeb5c48 --- /dev/null +++ b/packages/ecs/src/interface/registry/layout.ts @@ -0,0 +1,364 @@ +import type { Schema } from '../schema/schema.js'; +import { isString } from './schemas.js'; + +/** A path from a value to one scalar leaf: `[]` for a scalar, `[1]` for a tuple element, `["x"]` for a struct field. */ +export type Path = readonly (number | string)[]; + +/** A leaf's logical type. */ +export type Scalar = "f32" | "f64" | "i32" | "u32" | "boolean"; + +/** The typed array a leaf is stored through: booleans are `u8` on the CPU and `u32` in GPU layouts. */ +export type View = "f32" | "f64" | "i32" | "u32" | "u8"; + +/** Views of one component's bytes; a leaf reads the one its `view` names. */ +export type Views = { + readonly f32: Float32Array; + readonly f64: Float64Array; + readonly i32: Int32Array; + readonly u32: Uint32Array; + readonly u8: Uint8Array; +}; + +export type TypedArray = Views[keyof Views]; + +/** A scalar leaf at byte `offset` within each element. In elements of its view, a row's value is at `row * unitStride + unitOffset`. */ +export type Leaf = { + readonly path: Path; + readonly scalar: Scalar; + readonly view: View; + readonly offset: number; + readonly unitStride: number; + readonly unitOffset: number; +}; + +/** + * How a component's values are stored. + * - `constant`: a `const` schema; no storage, every value is `value`. + * - `linear`: interleaved elements of `stride` bytes in one column. `read` copies a value out; `write` replaces it. + * - `string`: the world's thread's values, as `object`'s, and a 4-byte handle per row, in one column, to each one's + * record in shared memory, which other threads read; only the world's thread writes them. + * - `object`: a JS array of values, on its logical thread only. + */ +export type Layout = + | { readonly kind: "constant"; readonly value: unknown } + | { + readonly kind: "linear"; + readonly leaves: readonly Leaf[]; + readonly stride: number; + readonly read: (views: Views, row: number) => unknown; + readonly write: (views: Views, row: number, value: unknown) => void; + } + | { readonly kind: "string"; readonly stride: 4 } + | { readonly kind: "object" }; + +/** The layout of every string. */ +const stringLayout: Layout = { kind: "string", stride: 4 }; + +/** The layout of values stored as they are. */ +export const objectLayout: Layout = { kind: "object" }; + +export const bytesOf = (view: View): number => view === "f64" ? 8 : view === "u8" ? 1 : 4; + +export const viewsOf = (buffer: ArrayBufferLike, byteOffset: number, byteLength: number): Views => ({ + f32: new Float32Array(buffer, byteOffset, byteLength >> 2), + f64: new Float64Array(buffer, byteOffset, Math.floor(byteLength / 8)), + i32: new Int32Array(buffer, byteOffset, byteLength >> 2), + u32: new Uint32Array(buffer, byteOffset, byteLength >> 2), + u8: new Uint8Array(buffer, byteOffset, byteLength), +}); + +/** A value's shape, before placement. */ +type Shape = + | { readonly kind: "scalar"; readonly scalar: Scalar } + | { readonly kind: "tuple"; readonly items: readonly Shape[] } + | { readonly kind: "struct"; readonly names: readonly string[]; readonly items: readonly Shape[] }; + +const integerScalar = ({ minimum, maximum }: { readonly minimum?: number; readonly maximum?: number }): Scalar => { + if (minimum === undefined || maximum === undefined) return "f64"; + if (minimum >= 0 && maximum <= 0xFFFFFFFF) return "u32"; + if (minimum >= -0x80000000 && maximum <= 0x7FFFFFFF) return "i32"; + return "f64"; +}; + +/** `null` if the schema isn't linear: numbers, booleans, fixed-length tuples and structs of those. */ +const shapeOf = (schema: Schema): Shape | null => { + if (!("type" in schema)) return null; + switch (schema.type) { + case "number": return { kind: "scalar", scalar: schema.precision === 1 ? "f32" : "f64" }; + case "integer": return { kind: "scalar", scalar: "format" in schema ? "f64" : integerScalar(schema) }; + case "boolean": return { kind: "scalar", scalar: "boolean" }; + case "array": { + const length = schema.minItems; + if (length === undefined || length !== schema.maxItems) return null; + const item = shapeOf(schema.items); + return item === null ? null : { kind: "tuple", items: new Array(length).fill(item) }; + } + case "object": { + const names = Object.keys(schema.properties); + const { required } = schema; + if (required !== undefined && names.some(name => !required.includes(name))) return null; + const items: Shape[] = []; + for (const name of names) { + const item = shapeOf(schema.properties[name]); + if (item === null) return null; + items.push(item); + } + return { kind: "struct", names, items }; + } + default: return null; + } +}; + +const roundUp = (value: number, alignment: number): number => Math.ceil(value / alignment) * alignment; + +/** A shape's placement: leaves at offsets relative to the shape's start. */ +type Placed = { readonly size: number; readonly align: number; readonly leaves: readonly { readonly path: Path; readonly scalar: Scalar; readonly view: View; readonly offset: number }[] }; + +type Rules = "cpu" | "storage" | "vertex"; + +const describe = (path: Path): string => path.length === 0 ? "the value" : `[${path.map(key => JSON.stringify(key)).join("][")}]`; + +const gpuScalar = (scalar: Scalar, path: Path): View => { + if (scalar === "f64") throw new Error(`${describe(path)} is f64, which isn't GPU compatible: use a number with precision: 1, or an integer with minimum and maximum within i32 or u32.`); + return scalar === "boolean" ? "u32" : scalar; +}; + +const sameScalar = (items: readonly Shape[]): Scalar | null => { + const first = items[0]; + if (first?.kind !== "scalar") return null; + return items.every(item => item.kind === "scalar" && item.scalar === first.scalar) ? first.scalar : null; +}; + +const place = (shape: Shape, path: Path, rules: Rules): Placed => { + if (shape.kind === "scalar") { + const view: View = rules === "cpu" ? (shape.scalar === "boolean" ? "u8" : shape.scalar) : gpuScalar(shape.scalar, path); + const size = bytesOf(view); + return { size, align: size, leaves: [{ path, scalar: shape.scalar, view, offset: 0 }] }; + } + const keys: readonly (number | string)[] = shape.kind === "struct" ? shape.names : shape.items.map((_, i) => i); + const items = shape.items.map((item, i) => place(item, [...path, keys[i]], rules)); + if (rules === "storage" && shape.kind === "tuple") { + const scalar = sameScalar(shape.items); + const length = shape.items.length; + // WGSL vec2/vec3/vec4: 4-byte components, aligned to 8, 16, 16; a vec3 is 12 bytes, so a scalar can follow it. + if (scalar !== null && length >= 2 && length <= 4) { + return { size: 4 * length, align: length === 2 ? 8 : 16, leaves: items.map((item, i) => ({ ...item.leaves[0], offset: 4 * i })) }; + } + if (!shape.items.every(item => JSON.stringify(item) === JSON.stringify(shape.items[0]))) { + throw new Error(`${describe(path)} mixes element types, which a WGSL array can't: use a struct.`); + } + // WGSL array: elements at a stride of T's size rounded up to its alignment. + const element = items[0]; + const stride = roundUp(element.size, element.align); + return { + size: stride * length, + align: element.align, + leaves: items.flatMap((item, i) => item.leaves.map(leaf => ({ ...leaf, offset: leaf.offset + i * stride }))), + }; + } + const align = rules === "vertex" ? 4 : Math.max(...items.map(item => item.align)); + return pack(items, align, rules === "vertex" ? 4 : 0); +}; + +/** Members in order, each at its alignment (or `memberAlign` when set), padded to the aggregate's alignment. */ +const pack = (items: readonly Placed[], align: number, memberAlign: number): Placed => { + let offset = 0; + const leaves: Placed["leaves"][number][] = []; + for (const item of items) { + offset = roundUp(offset, memberAlign === 0 ? item.align : Math.min(memberAlign, item.align)); + for (const leaf of item.leaves) leaves.push({ ...leaf, offset: offset + leaf.offset }); + offset += item.size; + } + return { size: roundUp(offset, align), align, leaves }; +}; + +type Node = { + readonly read: (views: Views, row: number) => unknown; + readonly write: (views: Views, row: number, value: unknown) => void; +}; + +const element = (value: unknown, key: number | string): unknown => + typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; + +/** A tuple of 2 to 4 same-typed scalars in consecutive elements of one view. */ +const lanesNode = (view: View, unitStride: number, unitOffset: number, length: number): Node => { + const write = (views: Views, row: number, value: unknown): void => { + const target = views[view]; + const index = row * unitStride + unitOffset; + if (Array.isArray(value)) for (let i = 0; i < length; i++) target[index + i] = Number(value[i]); + else for (let i = 0; i < length; i++) target[index + i] = Number(element(value, i)); + }; + if (length === 2) return { + read: (views, row) => { + const target = views[view]; + const index = row * unitStride + unitOffset; + return [target[index], target[index + 1]]; + }, + write, + }; + if (length === 3) return { + read: (views, row) => { + const target = views[view]; + const index = row * unitStride + unitOffset; + return [target[index], target[index + 1], target[index + 2]]; + }, + write, + }; + return { + read: (views, row) => { + const target = views[view]; + const index = row * unitStride + unitOffset; + return [target[index], target[index + 1], target[index + 2], target[index + 3]]; + }, + write, + }; +}; + +/** Builds read/write closures over placed leaves, consuming them in shape order. */ +const nodeOf = (shape: Shape, leaves: readonly Leaf[], next: { index: number }): Node => { + if (shape.kind === "scalar") { + const { view, unitStride, unitOffset } = leaves[next.index++]; + return shape.scalar === "boolean" + ? { read: (views, row) => views[view][row * unitStride + unitOffset] !== 0, write: (views, row, value) => { views[view][row * unitStride + unitOffset] = Number(value); } } + : { read: (views, row) => views[view][row * unitStride + unitOffset], write: (views, row, value) => { views[view][row * unitStride + unitOffset] = Number(value); } }; + } + const first = next.index; + const items = shape.items.map(item => nodeOf(item, leaves, next)); + const tupleLeaves = leaves.slice(first, next.index); + // A tuple of same-typed non-boolean scalars, like a vec3: one loop over its leaves. + if (shape.kind === "tuple" && shape.items.every(item => item.kind === "scalar" && item.scalar !== "boolean") && tupleLeaves.every(leaf => leaf.view === tupleLeaves[0].view)) { + const view = tupleLeaves[0].view; + const strides = tupleLeaves.map(leaf => leaf.unitStride); + const offsets = tupleLeaves.map(leaf => leaf.unitOffset); + const length = tupleLeaves.length; + const [{ unitStride, unitOffset }] = tupleLeaves; + // Lanes next to each other, as in a vec2, vec3 or vec4: array literals and straight-line stores. + if (length >= 2 && length <= 4 && tupleLeaves.every((leaf, i) => leaf.unitStride === unitStride && leaf.unitOffset === unitOffset + i)) return lanesNode(view, unitStride, unitOffset, length); + return { + read: (views, row) => { + const target = views[view]; + const value = new Array(length); + for (let i = 0; i < length; i++) value[i] = target[row * strides[i] + offsets[i]]; + return value; + }, + write: (views, row, value) => { + const target = views[view]; + if (Array.isArray(value)) for (let i = 0; i < length; i++) target[row * strides[i] + offsets[i]] = Number(value[i]); + else for (let i = 0; i < length; i++) target[row * strides[i] + offsets[i]] = Number(element(value, i)); + }, + }; + } + if (shape.kind === "tuple") { + return { + read: (views, row) => { + const value = new Array(items.length); + for (let i = 0; i < items.length; i++) value[i] = items[i].read(views, row); + return value; + }, + write: (views, row, value) => { + if (Array.isArray(value)) for (let i = 0; i < items.length; i++) items[i].write(views, row, value[i]); + else for (let i = 0; i < items.length; i++) items[i].write(views, row, element(value, i)); + }, + }; + } + const { names } = shape; + return { + read: (views, row) => { + const value: { [name: string]: unknown } = {}; + for (let i = 0; i < names.length; i++) value[names[i]] = items[i].read(views, row); + return value; + }, + write: (views, row, value) => { + for (let i = 0; i < names.length; i++) items[i].write(views, row, element(value, names[i])); + }, + }; +}; + +/** Whether code can be generated here; a content security policy without `'unsafe-eval'` forbids it. */ +let generating = true; + +/** A struct member's name as an object literal's key: quoted, and computed for `__proto__`, which a plain key would make the prototype. */ +const propertyOf = (name: string): string => name === "__proto__" ? `[${JSON.stringify(name)}]` : JSON.stringify(name); + +/** + * A struct's or nested tuple's read and write as straight-line code: one literal of its leaves' loads, and one store per + * leaf, rather than a call per leaf and properties added one at a time. Only numbers, view names and quoted member + * names are written into the code. A value that isn't an object is written through `fallback`. Null where code can't + * be generated. + */ +const generatedNode = (shape: Shape, leaves: readonly Leaf[], fallback: Node): Node | null => { + if (!generating) return null; + const at = (leaf: Leaf): string => `views.${leaf.view}[row * ${leaf.unitStride} + ${leaf.unitOffset}]`; + let next = 0; + const literal = (node: Shape): string => { + if (node.kind === "scalar") { + const leaf = leaves[next++]; + return leaf.scalar === "boolean" ? `${at(leaf)} !== 0` : at(leaf); + } + if (node.kind === "tuple") return `[${node.items.map(literal).join(", ")}]`; + return `{ ${node.items.map((item, i) => `${propertyOf(node.names[i])}: ${literal(item)}`).join(", ")} }`; + }; + const read = literal(shape); + next = 0; + let temporaries = 0; + // A member of `value` is read directly when `value` is known to be an object, and through `element` otherwise. + const stores = (node: Shape, value: string, object: boolean): string[] => { + if (node.kind === "scalar") return [`${at(leaves[next++])} = Number(${value});`]; + const keys = node.kind === "struct" ? node.names.map(name => JSON.stringify(name)) : node.items.map((_, i) => String(i)); + return node.items.flatMap((item, i) => { + const member = object ? `${value}[${keys[i]}]` : `element(${value}, ${keys[i]})`; + if (item.kind === "scalar") return stores(item, member, false); + const temporary = `t${temporaries++}`; + return [`const ${temporary} = ${member};`, ...stores(item, temporary, false)]; + }); + }; + const body = [ + `return { read: (views, row) => (${read}), write: (views, row, value) => {`, + `if (typeof value !== "object" || value === null) { fallback.write(views, row, value); return; }`, + ...stores(shape, "value", true), + "} };", + ].join("\n"); + try { + // The factory returns the node its body above makes. + const node: Node = new Function("element", "fallback", body)(element, fallback); + return node; + } + catch { + generating = false; + return null; + } +}; + +const gpuLayoutOf = (schema: Schema): "storage" | "vertex" | undefined => + "type" in schema && (schema.type === "object" || schema.type === "array") ? schema.layout : undefined; + +/** + * How a schema's values are stored. Throws when a schema declares a GPU `layout` it can't meet, + * naming the leaf and the fix, so registration fails rather than the GPU upload. + */ +/** A value without a schema isn't data, so it's stored as it is. */ +export const layoutOf = (schema: Schema | undefined): Layout => { + if (schema === undefined) return objectLayout; + if ("const" in schema) return { kind: "constant", value: schema.const }; + if (isString(schema)) return stringLayout; + const rules: Rules = gpuLayoutOf(schema) ?? "cpu"; + const shape = shapeOf(schema); + if (shape === null) { + if (rules !== "cpu") throw new Error(`A ${rules} layout needs numbers, integers, booleans, fixed-length tuples and structs with every property required.`); + return objectLayout; + } + const placed = place(shape, [], rules); + // An array of elements: each is padded to its alignment. + const stride = roundUp(placed.size, placed.align); + const leaves: Leaf[] = placed.leaves.map(leaf => ({ + ...leaf, + unitStride: stride / bytesOf(leaf.view), + unitOffset: leaf.offset / bytesOf(leaf.view), + })); + const built = nodeOf(shape, leaves, { index: 0 }); + // Scalars and tuples of scalars are already straight-line. + const node = shape.kind === "struct" || (shape.kind === "tuple" && shape.items.some(item => item.kind !== "scalar")) ? generatedNode(shape, leaves, built) ?? built : built; + return { kind: "linear", leaves, stride, read: node.read, write: node.write }; +}; + +export const samePath = (a: Path, b: Path): boolean => a.length === b.length && a.every((key, i) => key === b[i]); diff --git a/packages/ecs/src/interface/registry/names.test.ts b/packages/ecs/src/interface/registry/names.test.ts new file mode 100644 index 000000000..0e3738303 --- /dev/null +++ b/packages/ecs/src/interface/registry/names.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { AnyDeclaration } from "./declarations.js"; +import { identify } from "./ids.test-helper.js"; +import { claimKey, declarationOf, kindOf, labelOf } from "./names.js"; + +/** Declarations as registration records them; claiming a key doesn't check them. */ +const number: AnyDeclaration = { type: { type: "number" } }; +const boolean: AnyDeclaration = { type: { type: "boolean" } }; + +describe("claimKey", () => { + it("accepts a plain symbol, with or without a description", () => { + const described = Symbol("claimKey/described"); + const anonymous = Symbol(); + claimKey(described, "component", { type: { type: "number" } }); + claimKey(anonymous, "resource", number); + expect([kindOf(described), kindOf(anonymous)]).toEqual(["component", "resource"]); + expect(declarationOf(anonymous)).toBe(number); + }); + it("keys by identity: symbols with one description are different keys", () => { + const first = Symbol("claimKey/twin"); + const second = Symbol("claimKey/twin"); + claimKey(first, "component", { type: { type: "number" } }); + expect(() => claimKey(second, "resource", boolean)).not.toThrow(); + expect([kindOf(first), kindOf(second)]).toEqual(["component", "resource"]); + expect(kindOf("claimKey/twin")).toBe(undefined); + }); + it("rejects strings, Symbol.for symbols and well-known symbols, recording nothing", () => { + const shared = Symbol.for("claimKey/shared"); + expect(() => claimKey("claimKey/text", "component", { type: { type: "number" } })).toThrow(`Cannot register component "claimKey/text": a key is a plain Symbol(...), not a string.`); + expect(() => claimKey(1, "component", { type: { type: "number" } })).toThrow("a key is a plain Symbol(...), not a number."); + expect(() => claimKey(shared, "resource", number)).toThrow("a Symbol.for key is shared across the program, so another could claim it; use Symbol(...)."); + expect(() => claimKey(Symbol.iterator, "trait")).toThrow("Symbol.iterator is a well-known symbol."); + expect([kindOf(shared), kindOf(Symbol.iterator)]).toEqual([undefined, undefined]); + }); + it("rejects a key claimed as another kind", () => { + const key = Symbol("claimKey/crossKind"); + claimKey(key, "trait"); + expect(() => claimKey(key, "component", { type: { type: "number" } })).toThrow(`Cannot register component "claimKey/crossKind": already registered as a trait.`); + expect(kindOf(key)).toBe("trait"); + }); +}); + +describe("labelOf", () => { + it("labels a key by its description while no other registered key has it, else by its id", () => { + const first = Symbol("labelOf/shared"); + const second = Symbol("labelOf/shared"); + const lone = Symbol("labelOf/lone"); + identify(first, "a.ts#first"); + identify(second, "b.ts#second"); + identify(lone, "c.ts#lone"); + expect([labelOf(first), labelOf(lone)]).toEqual(["labelOf/shared", "labelOf/lone"]); + claimKey(first, "component", number); + claimKey(lone, "component", number); + expect([labelOf(first), labelOf(second), labelOf(lone)]).toEqual(["labelOf/shared", "b.ts#second", "labelOf/lone"]); + claimKey(second, "resource", number); + expect([labelOf(first), labelOf(second), labelOf(lone)]).toEqual(["a.ts#first", "b.ts#second", "labelOf/lone"]); + }); + it("labels a key without an id by its description, numbered by registration when another key has it", () => { + const first = Symbol("labelOf/twin"); + const second = Symbol("labelOf/twin"); + const third = Symbol("labelOf/twin"); + expect(labelOf(first)).toBe("labelOf/twin"); + claimKey(second, "component", { type: { type: "number" } }); + claimKey(first, "component", { type: { type: "number" } }); + claimKey(third, "trait"); + expect([labelOf(first), labelOf(second), labelOf(third)]).toEqual(["labelOf/twin#2", "labelOf/twin", "labelOf/twin#3"]); + }); + it("gives a key without a description a numbered label, and names other values as text", () => { + const anonymous = Symbol(); + expect(labelOf(anonymous)).toBe("Symbol()"); + claimKey(anonymous, "trait"); + expect(labelOf(anonymous)).toMatch(/^Symbol\(\)(#\d+)?$/); + expect([labelOf("text"), labelOf(1)]).toEqual(["text", "1"]); + }); +}); diff --git a/packages/ecs/src/interface/registry/names.ts b/packages/ecs/src/interface/registry/names.ts new file mode 100644 index 000000000..e3421bb05 --- /dev/null +++ b/packages/ecs/src/interface/registry/names.ts @@ -0,0 +1,154 @@ +import type { AnyDeclaration } from './declarations.js'; +import { idOfKey } from './ids.js'; +import { sameItem } from './items.js'; + +/** What a key names. */ +export type Kind = "component" | "resource" | "trait"; + +/** + * A registered key: its kind, its declaration (none for a trait), its description, its id if the plugin gave one, and + * the label it has without one, numbered when another key had its description; shared by every symbol registered under + * its id. `first` is the symbol registered first, `newest` the one registered last. + */ +type Entry = { + readonly kind: Kind; + readonly declaration: AnyDeclaration | undefined; + readonly description: string; + readonly id: string | undefined; + readonly numbered: string; + readonly first: symbol; + newest: symbol; +}; + +/** Every component, resource and user trait, by symbol identity: symbols of one id share an entry. */ +const entries = new Map(); + +/** Entries by the ids the plugin gave their keys. */ +const identified = new Map(); + +/** Numbered labels given, so each key's is unique. */ +const labels = new Set(); + +/** How many registered keys have each description. */ +const described = new Map(); + +/** A symbol's description, `Symbol()` without one. */ +const descriptionOf = (key: symbol): string => key.description === undefined || key.description === "" ? "Symbol()" : key.description; + +/** `Symbol.iterator` and the rest, which the platform shares, so they can't be keys. */ +const wellKnown: ReadonlyMap = new Map(Reflect.ownKeys(Symbol).flatMap((name): [symbol, string][] => { + const value: unknown = Reflect.get(Symbol, name); + return typeof value === "symbol" ? [[value, `Symbol.${String(name)}`]] : []; +})); + +/** Whether a key is a component, a resource or a trait. */ +export const kindOf = (key: unknown): Kind | undefined => typeof key === "symbol" ? entries.get(key)?.kind : undefined; + +/** The declaration of a registered component or resource. */ +export const declarationOf = (key: unknown): AnyDeclaration | undefined => typeof key === "symbol" ? entries.get(key)?.declaration : undefined; + +/** Whether two values are one key: the same value, or symbols registered under one id. */ +export const sameKey = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (typeof a !== "symbol" || typeof b !== "symbol") return false; + const entry = entries.get(a); + return entry !== undefined && entry === entries.get(b); +}; + +/** The symbol first registered as this key, which stands for it wherever keys are compared; any other value as it is. */ +export const firstKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.first ?? key : key; + +/** The symbol last registered as this key, by which a world gives values; any other value as it is. */ +export const newestKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.newest ?? key : key; + +/** + * How errors and reports name a key: its description, `Symbol()` without one, while no other registered key has it; + * else its id when the plugin gave it one, and without one, for a registered key, its description numbered by + * registration order. Any other value is named as text. + */ +export const labelOf = (key: unknown): string => { + if (typeof key !== "symbol") return String(key); + const known = wellKnown.get(key); + if (known !== undefined) return known; + const entry = entries.get(key); + const description = entry?.description ?? descriptionOf(key); + if ((described.get(description) ?? 0) <= (entry === undefined ? 0 : 1)) return description; + return entry === undefined ? idOfKey(key) ?? description : entry.id ?? entry.numbered; +}; + +/** A label for a key registered now without an id, its description numbered when another key's has it. */ +const numberedFor = (description: string): string => { + let label = description; + for (let n = 2; labels.has(label); n++) label = `${description}#${n}`; + labels.add(label); + return label; +}; + +/** Throws, naming the key, for a key registration can't take. */ +export const failRegistration = (kind: Kind, key: unknown, message: string): never => { + throw new Error(`Cannot register ${kind} "${labelOf(key)}": ${message}`); +}; + +/** The key, or a throw for one that isn't a plain `Symbol(...)`: a `Symbol.for` or well-known symbol is shared. */ +export const requireKey = (kind: Kind, key: unknown): symbol => { + if (typeof key !== "symbol") return failRegistration(kind, key, `a key is a plain Symbol(...), not a ${typeof key}.`); + if (Symbol.keyFor(key) !== undefined) return failRegistration(kind, key, "a Symbol.for key is shared across the program, so another could claim it; use Symbol(...)."); + const known = wellKnown.get(key); + if (known !== undefined) return failRegistration(kind, key, `${known} is a well-known symbol.`); + return key; +}; + +/** + * Whether two declarations are the same, as a module evaluated again declares them, such as on a hot reload. Keys + * compare as `sameKey` does, and systems and finalizers they list as `sameItem` does, since evaluating their modules + * again makes new values of them. + */ +export const sameDeclaration = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (typeof a === "symbol" || typeof b === "symbol") return sameKey(a, b); + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null || Array.isArray(a) !== Array.isArray(b)) return false; + if ("declaration" in a && "declaration" in b) return sameItem(a, b); + // A set or map, such as a default, compares by its entries in order, as a module evaluated again makes them. + if (a instanceof Set) return b instanceof Set && sameDeclaration(Array.from(a), Array.from(b)); + if (a instanceof Map) return b instanceof Map && sameDeclaration(Array.from(a), Array.from(b)); + if (b instanceof Set || b instanceof Map) return false; + const keys = Reflect.ownKeys(a); + const others = Reflect.ownKeys(b); + return keys.length === others.length && keys.every(key => { + const other = typeof key === "symbol" ? others.find(candidate => sameKey(key, candidate)) : key; + return other !== undefined && Reflect.has(b, other) && sameDeclaration(Reflect.get(a, key), Reflect.get(b, other)); + }); +}; + +/** + * Records a key as a component, resource or trait, with its declaration. A symbol whose id is already a registered + * key's, of its kind with the same declaration, as a module evaluated again makes it, is that key. Throws for a key + * that isn't a plain `Symbol(...)`, one already registered, or one whose id is another kind's or has another + * declaration. + */ +export const claimKey = (key: unknown, kind: Kind, declaration?: AnyDeclaration): void => { + const symbol = requireKey(kind, key); + const existing = entries.get(symbol); + if (existing !== undefined) { + failRegistration(kind, symbol, existing.kind === kind ? `already registered as a ${kind}, with another declaration.` : `already registered as a ${existing.kind}.`); + } + const id = idOfKey(symbol); + const named = id === undefined ? undefined : identified.get(id); + if (named !== undefined) { + if (named.kind !== kind) failRegistration(kind, symbol, `its id is a ${named.kind}'s.`); + if (!sameDeclaration(named.declaration, declaration)) { + failRegistration(kind, symbol, "its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page."); + } + named.newest = symbol; + entries.set(symbol, named); + return; + } + const description = descriptionOf(symbol); + const entry: Entry = { kind, declaration, description, id, numbered: numberedFor(description), first: symbol, newest: symbol }; + described.set(description, (described.get(description) ?? 0) + 1); + if (id !== undefined) identified.set(id, entry); + entries.set(symbol, entry); +}; + +/** The symbol last registered under an id, by which a world gives values, or undefined where none was. */ +export const keyOfId = (id: string): symbol | undefined => identified.get(id)?.newest; diff --git a/packages/ecs/src/interface/registry/program.ts b/packages/ecs/src/interface/registry/program.ts new file mode 100644 index 000000000..1450f3976 --- /dev/null +++ b/packages/ecs/src/interface/registry/program.ts @@ -0,0 +1,111 @@ +import type { Finalizer } from '../finalizer/created.js'; +import type { Rule } from '../rule/created.js'; +import type { Definition } from '../system/definition.js'; +import { recordItem, registeredAs } from './items.js'; +import { declarationOf, firstKeyOf, labelOf, sameDeclaration } from './names.js'; +import { orderFinalizers } from './finalizer-order.js'; +import { strataOf, type RuleFacts } from './strata.js'; + +/** What a system, rule or finalizer constructor recorded. */ +export type Item = Definition | Rule | Finalizer; + +type Key = symbol; + +const rules: Rule[] = []; +/** In the order every world runs them. */ +let finalizers: readonly Finalizer[] = []; +let created = 0; + +/** + * Records a system under the id the plugin gave its declaration. Declared again under that id the same, as a module + * evaluated again declares it, it's a new value of the same system. Throws for that id with another kind or declaration. + */ +export const claimSystem = (system: Definition, id: string | undefined): void => { + recordItem(system, id, registeredAs(system, id, sameDeclaration)); +}; + +/** Every rule's facts with the `within` hosts of their components, numbered for this check only. Throws for a cycle. */ +const checkStrata = (all: readonly Rule[]): void => { + const ids = new Map(); + const keys: Key[] = []; + const hosts: number[] = []; + const idOf = (symbol: Key): number => { + const key = firstKeyOf(symbol); + const existing = ids.get(key); + if (existing !== undefined) return existing; + const id = keys.length; + ids.set(key, id); + keys.push(key); + hosts.push(-1); + const within = declarationOf(key)?.within; + if (typeof within === "symbol") hosts[id] = idOf(within); + return id; + }; + const facts = all.map((rule): RuleFacts => { + const { components, derive } = rule.declaration; + return { has: (components.has ?? []).map(idOf), not: (components.not ?? []).map(idOf), derives: derive.map(idOf) }; + }); + strataOf(facts, hosts, id => labelOf(keys[id])); +}; + +/** + * Records a rule under the id the plugin gave its declaration, which applies in every world, and returns it; declared + * again under that id the same, it's the rule already recorded. Throws for a cycle through a negation, and for that id + * with another kind or declaration. + */ +export const addRule = (rule: Rule, id: string | undefined): Rule => { + const existing = registeredAs(rule, id, sameDeclaration); + const found = existing === undefined ? undefined : rules.find(other => other === existing); + if (found !== undefined) return found; + checkStrata([...rules, rule]); + recordItem(rule, id, undefined); + rules.push(rule); + created++; + return rule; +}; + +/** + * Records a finalizer under the id the plugin gave its declaration, which applies in every world, and returns it; + * declared again under that id the same, it's the finalizer already recorded, which keeps its place with the new body. + * Throws for an order that cycles or leaves a resource conflict, and for that id with another kind or declaration. + */ +export const addFinalizer = (finalizer: Finalizer, id: string | undefined): Finalizer => { + const registered = registeredAs(finalizer, id, sameDeclaration); + const existing = registered === undefined ? undefined : finalizers.find(other => other === registered); + if (existing?.kind === "finalizer" && finalizer.kind === "finalizer") { + existing.body = finalizer.body; + return existing; + } + if (existing?.kind === "changes" && finalizer.kind === "changes") { + existing.body = finalizer.body; + return existing; + } + const ordered = orderFinalizers([...finalizers, finalizer]); + recordItem(finalizer, id, undefined); + finalizers = ordered; + created++; + return finalizer; +}; + +/** Only constructors make values with a system's kind and a declaration. */ +const isDefinition = (system: object): system is Definition => + "declaration" in system && "kind" in system && (system.kind === "system" || system.kind === "render" || system.kind === "composite"); + +/** A system's definition: the value a System constructor returned, or undefined for any other value. */ +export const definitionOf = (system: { readonly name: string }): Definition | undefined => isDefinition(system) ? system : undefined; + +/** A system's definition. Throws for a value no constructor returned. */ +export const requireDefinition = (system: { readonly name: string }): Definition => { + const definition = definitionOf(system); + if (definition === undefined) throw new Error(`"${String(system.name)}" isn't a system a System constructor returned.`); + return definition; +}; + +/** Counts rules and finalizers recorded, so a world can tell when one was added since it last looked. */ +export const programVersion = (): number => created; + +/** The program's rules, in creation order. */ +export const programRules = (): readonly Rule[] => rules; + +/** The program's finalizers, in the order every world runs them. */ +export const programFinalizers = (): readonly Finalizer[] => finalizers; diff --git a/packages/ecs/src/interface/registry/reduction.ts b/packages/ecs/src/interface/registry/reduction.ts new file mode 100644 index 000000000..58d3f9efe --- /dev/null +++ b/packages/ecs/src/interface/registry/reduction.ts @@ -0,0 +1,7 @@ +import type { Reduction } from '../resource/reduction.js'; + +/** Every reduction operator. */ +export const reductionOperators: readonly Reduction[] = ["+", "*", "min", "max", "&", "|", "^"]; + +/** Whether a value is a reduction operator. */ +export const isReduction = (value: unknown): value is Reduction => reductionOperators.some(operator => operator === value); diff --git a/packages/ecs/src/interface/registry/schedule.ts b/packages/ecs/src/interface/registry/schedule.ts new file mode 100644 index 000000000..e4556e8aa --- /dev/null +++ b/packages/ecs/src/interface/registry/schedule.ts @@ -0,0 +1,215 @@ +import type { Schema } from '../schema/schema.js'; +import type { Definition } from '../system/definition.js'; +import type { Leaf } from '../system/leaf.js'; +import { firstItemOf, labelOfItem } from './items.js'; +import { requireDefinition } from './program.js'; +import { firstKeyOf, labelOf, sameDeclaration } from './names.js'; +import { lookupKeysOf, orderKeysOf } from './indexes.js'; + +type Key = symbol; + +/** Throws unless each leaf's input is an object schema of named fields, and a field several declare has one schema. */ +const checkInputs = (leaves: readonly Leaf[]): void => { + const fields = new Map(); + for (const leaf of leaves) { + const { input } = leaf.declaration; + const name = labelOfItem(leaf); + if (input === undefined) continue; + const schema: Schema = input; + if (!("type" in schema) || schema.type !== "object" || typeof schema.properties !== "object" || schema.properties === null) { + throw new Error(`System "${name}": input is an object schema of named fields.`); + } + for (const [field, property] of Object.entries(schema.properties)) { + const seen = fields.get(field); + if (seen === undefined) fields.set(field, { schema: property, system: name }); + else if (!sameDeclaration(seen.schema, property)) { + throw new Error(`Systems "${seen.system}" and "${name}" declare input field "${field}" with different schemas.`); + } + } + } +}; + +/** + * Leaves in run order, grouped into tiers. Leaves in one tier have no ordering path between them. `reductions` are the + * resources its leaves reduce into, each with how many of them contribute. `observer` are its observer leaves, in no + * tier: they run after its sync point, ordered with nothing. + */ +export type Schedule = { readonly tiers: readonly (readonly Leaf[])[]; readonly reductions: ReadonlyMap; readonly observer: readonly Leaf[] }; + +export const isLeaf = (system: Definition): system is Leaf => system.kind !== "composite"; + +/** Whether a system is an observer leaf, which runs after a sync point rather than in a tier. */ +export const isObserver = (system: Definition): system is Leaf => system.kind !== "composite" && system.declaration.observer === true; + +/** Each system is an interval, from its start node to its end node. */ +const start = (unit: number): number => 2 * unit; +const end = (unit: number): number => 2 * unit + 1; + +type Graph = { readonly units: readonly Definition[]; readonly successors: readonly Set[] }; + +/** + * A leaf runs from its start to its end, and a composite spans its members. `after` and `before` order one system's end + * before another's start, and `during` places a system inside another's span. The root's own constraints are ignored, + * as are constraints naming systems outside it. Values of one system, as a module evaluated again makes them, are one + * unit, the first reached. + */ +const graphOf = (root: Definition): Graph => { + const units: Definition[] = []; + const members: (readonly number[])[] = []; + const indexOf = new Map(); + const visit = (system: Definition): number => { + const first = firstItemOf(system); + const known = indexOf.get(first); + if (known !== undefined) return known; + const unit = units.push(system) - 1; + indexOf.set(first, unit); + members[unit] = isLeaf(system) ? [] : system.declaration.systems.map(member => visit(requireDefinition(member))); + return unit; + }; + visit(root); + const successors = units.flatMap(() => [new Set(), new Set()]); + // An observer system is ordered with nothing, so constraints naming one are ignored. + const resolve = (systems: readonly object[] | undefined): readonly number[] => + (systems ?? []).flatMap(system => { + const unit = indexOf.get(firstItemOf(system)); + return unit === undefined || isObserver(units[unit]) ? [] : [unit]; + }); + units.forEach((system, unit) => { + successors[start(unit)].add(end(unit)); + for (const member of members[unit]) { + successors[start(unit)].add(start(member)); + successors[end(member)].add(end(unit)); + } + if (system === root) return; + const { before, after, during } = system.declaration; + for (const other of resolve(after)) successors[end(other)].add(start(unit)); + for (const other of resolve(before)) successors[end(unit)].add(start(other)); + for (const other of resolve(during)) { + successors[start(other)].add(start(unit)); + successors[end(unit)].add(end(other)); + } + }); + return { units, successors }; +}; + +/** Kahn's algorithm over nodes. Throws on a cycle. */ +const topologicalOrder = ({ units, successors }: Graph): number[] => { + const incoming = successors.map(() => 0); + for (const next of successors) for (const node of next) incoming[node]++; + const order: number[] = []; + const ready = successors.map((_, node) => node).filter(node => incoming[node] === 0); + while (ready.length > 0) { + const node = ready.pop() ?? 0; + order.push(node); + for (const next of successors[node]) if (--incoming[next] === 0) ready.push(next); + } + if (order.length < successors.length) { + const cyclic = units.filter((_, unit) => incoming[start(unit)] > 0 || incoming[end(unit)] > 0).map(system => `"${labelOfItem(system)}"`); + throw new Error(`Ordering cycle between ${cyclic.join(", ")}.`); + } + return order; +}; + +/** `reach[a]` has bit `b` set when there's a path from node `a` to node `b`. */ +const reachability = (order: readonly number[], successors: readonly Set[]): Uint32Array[] => { + const words = Math.ceil(order.length / 32); + const reach = order.map(() => new Uint32Array(words)); + for (let i = order.length - 1; i >= 0; i--) { + const node = order[i]; + for (const next of successors[node]) { + reach[node][next >>> 5] |= 1 << (next & 31); + for (let w = 0; w < words; w++) reach[node][w] |= reach[next][w]; + } + } + return reach; +}; + +const reaches = (reach: readonly Uint32Array[], from: number, to: number): boolean => (reach[from][to >>> 5] & (1 << (to & 31))) !== 0; + +/** Whether one of two systems ends before the other starts. */ +const ordered = (reach: readonly Uint32Array[], a: number, b: number): boolean => reaches(reach, end(a), start(b)) || reaches(reach, end(b), start(a)); + +/** Each node's tier: the most leaves on any path to it, so a leaf's tier is where it starts. */ +const depthsOf = ({ units, successors }: Graph, order: readonly number[]): number[] => { + const depth = successors.map(() => 0); + for (const node of order) { + const step = node % 2 === 0 && isLeaf(units[node >>> 1]) && !isObserver(units[node >>> 1]) ? 1 : 0; + for (const next of successors[node]) depth[next] = Math.max(depth[next], depth[node] + (next === node + 1 ? step : 0)); + } + return depth; +}; + +type Access = { readonly reads: ReadonlySet; readonly writes: ReadonlySet; readonly reduces: ReadonlySet }; + +/** The resources a leaf reduces into. */ +export const reducedBy = (leaf: Leaf): readonly Key[] => { + const { reduce } = leaf.declaration; + return reduce === undefined ? [] : Object.getOwnPropertySymbols(reduce).filter(key => Reflect.get(reduce, key) !== undefined); +}; + +/** + * Reads include change filters, since they observe writers' ticks, and the key parts of indexes looked up through or + * ordered by, which the index is found and kept by. Presence-only lists don't conflict. Keys are each one's first + * symbol. + */ +export const accessOf = (leaf: Leaf): Access => { + const { components, lookup, resources, up } = leaf.declaration; + const reads = new Set([...components?.read ?? [], ...components?.added ?? [], ...components?.changed ?? [], ...resources?.read ?? [], ...resources?.changed ?? []].map(firstKeyOf)); + for (const name in lookup) for (const key of [...lookup[name].read ?? [], ...lookupKeysOf(lookup[name])]) reads.add(firstKeyOf(key)); + for (const key of orderKeysOf(leaf.declaration)) reads.add(firstKeyOf(key)); + if (up !== undefined) for (const key of [up.through, ...up.read ?? []]) reads.add(firstKeyOf(key)); + const writes = new Set([...components?.write ?? [], ...resources?.write ?? []].map(firstKeyOf)); + return { reads, writes, reduces: new Set(reducedBy(leaf).map(firstKeyOf)) }; +}; + +/** Contributors to one resource don't conflict with each other: their fold doesn't depend on which runs first. */ +const conflictOf = (a: Access, b: Access): Key | undefined => { + for (const key of a.writes) if (b.writes.has(key) || b.reads.has(key) || b.reduces.has(key)) return key; + for (const key of b.writes) if (a.reads.has(key) || a.reduces.has(key)) return key; + return undefined; +}; + +const schedules = new WeakMap(); + +/** A system's schedule, created once per system. Throws on a cycle, or on two conflicting leaves with no ordering path between them. */ +export const scheduleOf = (system: Definition): Schedule => { + let schedule = schedules.get(system); + if (schedule === undefined) schedules.set(system, schedule = createSchedule(system)); + return schedule; +}; + +/** + * Orders a system's leaves into tiers. Throws on a cycle, on two conflicting leaves with no ordering path between them, + * or on inputs that don't merge. + */ +const createSchedule = (system: Definition): Schedule => { + const graph = graphOf(system); + const observer = graph.units.filter(isObserver); + const leaves = graph.units.flatMap((unit, index) => isLeaf(unit) && !isObserver(unit) ? [{ leaf: unit, unit: index }] : []); + checkInputs(leaves.map(({ leaf }) => leaf)); + const accesses = leaves.map(({ leaf }) => accessOf(leaf)); + // Leaves that read a resource run after every leaf that reduces into it, so they read the finished fold. + const reductions = new Map(); + accesses.forEach((access, contributor) => { + for (const key of access.reduces) { + reductions.set(key, (reductions.get(key) ?? 0) + 1); + accesses.forEach((other, reader) => { + if (reader !== contributor && other.reads.has(key)) graph.successors[end(leaves[contributor].unit)].add(start(leaves[reader].unit)); + }); + } + }); + const order = topologicalOrder(graph); + const reach = reachability(order, graph.successors); + for (let a = 0; a < leaves.length; a++) { + for (let b = a + 1; b < leaves.length; b++) { + const key = conflictOf(accesses[a], accesses[b]); + if (key !== undefined && !ordered(reach, leaves[a].unit, leaves[b].unit)) { + throw new Error(`Systems "${labelOfItem(leaves[a].leaf)}" and "${labelOfItem(leaves[b].leaf)}" both access "${labelOf(key)}" and one writes it; order them with before or after.`); + } + } + } + const depth = depthsOf(graph, order); + const tiers: Leaf[][] = []; + for (const { leaf, unit } of leaves) (tiers[depth[start(unit)]] ??= []).push(leaf); + return { tiers: tiers.filter(tier => tier !== undefined), reductions, observer }; +}; diff --git a/packages/ecs/src/interface/registry/schemas.ts b/packages/ecs/src/interface/registry/schemas.ts new file mode 100644 index 000000000..19b9776f7 --- /dev/null +++ b/packages/ecs/src/interface/registry/schemas.ts @@ -0,0 +1,61 @@ +import type { Schema } from '../schema/schema.js'; + +/** Whether a constant is a struct's value: numbers and booleans, in fixed arrays and objects. */ +const isStructValue = (value: unknown): boolean => + typeof value === "number" || typeof value === "boolean" + || (Array.isArray(value) ? value.every(isStructValue) : typeof value === "object" && value !== null && Object.values(value).every(isStructValue)); + +/** Whether a schema describes a struct: numbers, booleans, entities, fixed-length tuples and structs of those. */ +export const isStruct = (schema: Schema): boolean => { + if ("const" in schema) return isStructValue(schema.const); + if (!("type" in schema)) return false; + switch (schema.type) { + case "number": + case "integer": + case "boolean": + return true; + case "array": + return schema.minItems !== undefined && schema.minItems === schema.maxItems && isStruct(schema.items); + case "object": { + const { required, properties } = schema; + const names = Object.keys(properties); + return (required === undefined || names.every(name => required.includes(name))) && names.every(name => isStruct(properties[name])); + } + default: + return false; + } +}; + +const isNull = (schema: Schema): boolean => ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null); + +/** Whether a schema describes a string: any string, a string `const`, or a union of those, with `null` or without. */ +export const isString = (schema: Schema): boolean => { + if ("const" in schema) return typeof schema.const === "string"; + if ("anyOf" in schema) return schema.anyOf.some(isString) && schema.anyOf.every(member => isString(member) || isNull(member)); + return "type" in schema && schema.type === "string"; +}; + +const hasBooleanValue = (value: unknown): boolean => + typeof value === "boolean" || (typeof value === "object" && value !== null && Object.values(value).some(hasBooleanValue)); + +/** Whether a struct schema has a boolean leaf. */ +export const hasBoolean = (schema: Schema): boolean => { + if ("const" in schema) return hasBooleanValue(schema.const); + if (!("type" in schema)) return false; + if (schema.type === "boolean") return true; + if (schema.type === "array") return hasBoolean(schema.items); + if (schema.type === "object") return Object.values(schema.properties).some(hasBoolean); + return false; +}; + +/** Whether a schema accepts `null`, so a value of it may start there. */ +export const allowsNull = (schema: Schema): boolean => + ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNull)); + +/** Whether a schema is an entity's. */ +export const isEntity = (schema: Schema): boolean => "format" in schema && schema.format === "entity"; + +/** Whether a schema accepts `null` or `false`, so a system can run on the condition of a value of it. */ +export const conditional = (schema: Schema): boolean => + allowsNull(schema) || ("type" in schema && schema.type === "boolean") || ("const" in schema && schema.const === false) + || ("anyOf" in schema && schema.anyOf.some(conditional)); diff --git a/packages/ecs/src/interface/registry/strata.ts b/packages/ecs/src/interface/registry/strata.ts new file mode 100644 index 000000000..18fb364e7 --- /dev/null +++ b/packages/ecs/src/interface/registry/strata.ts @@ -0,0 +1,93 @@ +/** + * Structural derivation: which components an archetype's entities have once rules and `within` are applied. + * Datalog with stratified negation over the components each entity has. A rule adds the components it derives where + * its query matches; a component declared within a host stays only while the host is present. Each stratum is a least + * fixed point, evaluated after every stratum it depends on, so the result is a pure function of the archetype. + */ + +/** A rule as component ids. */ +export type RuleFacts = { readonly has: readonly number[]; readonly not: readonly number[]; readonly derives: readonly number[] }; + +/** Strata in evaluation order: the rules deriving into each, with only that stratum's components, and its `within` dependents. */ +export type Strata = readonly { + readonly rules: readonly { readonly has: readonly number[]; readonly not: readonly number[]; readonly derives: readonly number[] }[]; + readonly within: readonly { readonly dependent: number; readonly host: number }[]; +}[]; + +type Edge = { readonly to: number; readonly negative: boolean; readonly within: boolean }; + +/** + * Orders rules and `within` declarations into strata. Throws for a cycle through a negation, which could oscillate, + * and for a cycle through `within`. `hosts[id]` is the host of a component declared within one, or -1. + */ +export const strataOf = (rules: readonly RuleFacts[], hosts: readonly number[], nameOf: (id: number) => string): Strata => { + const edges = new Map(); + const nodes = new Set(); + const edge = (from: number, to: number, negative: boolean, within: boolean): void => { + nodes.add(from); + nodes.add(to); + let list = edges.get(from); + if (list === undefined) edges.set(from, list = []); + list.push({ to, negative, within }); + }; + for (const rule of rules) { + for (const derived of rule.derives) { + for (const id of rule.has) edge(id, derived, false, false); + for (const id of rule.not) edge(id, derived, true, false); + nodes.add(derived); + } + } + hosts.forEach((host, dependent) => { if (host >= 0) edge(host, dependent, false, true); }); + // Tarjan's algorithm: each component is emitted after every component it reaches, so dependents come out first. + const index = new Map(); + const low = new Map(); + const stack: number[] = []; + const onStack = new Set(); + const components: number[][] = []; + let counter = 0; + const visit = (node: number): void => { + index.set(node, counter); + low.set(node, counter); + counter++; + stack.push(node); + onStack.add(node); + for (const { to } of edges.get(node) ?? []) { + if (!index.has(to)) { + visit(to); + low.set(node, Math.min(low.get(node) ?? 0, low.get(to) ?? 0)); + } + else if (onStack.has(to)) low.set(node, Math.min(low.get(node) ?? 0, index.get(to) ?? 0)); + } + if (low.get(node) !== index.get(node)) return; + const component: number[] = []; + for (;;) { + const member = stack.pop(); + if (member === undefined) break; + onStack.delete(member); + component.push(member); + if (member === node) break; + } + components.push(component); + }; + for (const node of nodes) if (!index.has(node)) visit(node); + components.reverse(); + return components.map(members => { + const inside = new Set(members); + for (const member of members) { + for (const { to, negative, within } of edges.get(member) ?? []) { + if (!inside.has(to)) continue; + const cycle = members.map(nameOf).sort().join(", "); + if (negative) throw new Error(`Rules derive in a cycle through a negation, which could oscillate: ${cycle}.`); + if (within) throw new Error(`Components are within each other in a cycle: ${cycle}.`); + } + } + return { + rules: rules.flatMap(rule => { + const derives = rule.derives.filter(id => inside.has(id)); + return derives.length === 0 ? [] : [{ has: rule.has, not: rule.not, derives }]; + }), + within: members.flatMap(dependent => (hosts[dependent] ?? -1) >= 0 ? [{ dependent, host: hosts[dependent] ?? -1 }] : []), + }; + }); +}; + diff --git a/packages/ecs/src/interface/registry/threads.ts b/packages/ecs/src/interface/registry/threads.ts new file mode 100644 index 000000000..b667c574e --- /dev/null +++ b/packages/ecs/src/interface/registry/threads.ts @@ -0,0 +1,73 @@ +import { threadOfDeclaration } from './declarations.js'; +import { lookupKeysOf, orderKeysOf } from './indexes.js'; +import { declarationOf, kindOf, labelOf } from './names.js'; + +type Key = symbol; +type Lists = { + readonly read?: readonly Key[]; + readonly write?: readonly Key[]; + readonly added?: readonly Key[]; + readonly changed?: readonly Key[]; + readonly is?: readonly Key[]; +}; + +/** + * What places a system or finalizer: the names it reads, writes or filters on by change, but not `has` and `not`, and + * the key parts of the indexes it looks up through or orders its rows by. + */ +export type Placed = { + readonly components?: Lists; + readonly lookup?: { readonly [name: string]: { readonly read?: readonly Key[]; readonly index?: unknown } }; + readonly up?: { readonly read?: readonly Key[] }; + readonly order?: unknown; + readonly resources?: Lists; +}; + +/** Throws unless each key is registered as `kind`. */ +export const requireRegistered = (what: "System" | "Finalizer", name: string, kind: "component" | "resource", keys: readonly Key[] | undefined): void => { + for (const key of keys ?? []) { + if (declarationOf(key) === undefined || kindOf(key) !== kind) { + throw new Error(`${what} "${name}" uses ${kind} "${labelOf(key)}", which isn't a registered ${kind}; import the module that registers it.`); + } + } +}; + +/** + * The thread a system or finalizer runs on: the one its object components and resources live on, or null when it uses + * none. Throws when they live on different threads, or a name it's placed by isn't registered as its kind. + */ +export const threadOf = (declaration: Placed, what: "System" | "Finalizer", name: string): string | null => { + /** By thread, the labels of the keys placing it there. */ + const threads = new Map>(); + const add = (kind: "component" | "resource", keys: readonly Key[] | undefined): void => { + requireRegistered(what, name, kind, keys); + for (const key of keys ?? []) { + const found = declarationOf(key); + const thread = found === undefined ? null : threadOfDeclaration(found); + if (thread === null) continue; + const labels = threads.get(thread) ?? new Set(); + labels.add(`"${labelOf(key)}"`); + threads.set(thread, labels); + } + }; + const { components, lookup, up, resources } = declaration; + add("component", components?.read); + add("component", components?.write); + add("component", components?.added); + add("component", components?.changed); + for (const name in lookup) { + add("component", lookup[name].read); + add("component", lookupKeysOf(lookup[name])); + } + add("component", up?.read); + add("component", orderKeysOf(declaration)); + add("resource", resources?.read); + add("resource", resources?.write); + add("resource", resources?.changed); + add("resource", resources?.is); + if (threads.size > 1) { + const placed = [...threads].map(([thread, labels]) => `${[...labels].join(", ")} on "${thread}"`).join(" and "); + throw new Error(`${what} "${name}" uses ${placed}; its object components and resources live on one thread.`); + } + return threads.keys().next().value ?? null; +}; diff --git a/packages/ecs/src/interface/registry/traits.ts b/packages/ecs/src/interface/registry/traits.ts new file mode 100644 index 000000000..ebc857306 --- /dev/null +++ b/packages/ecs/src/interface/registry/traits.ts @@ -0,0 +1,12 @@ +import { claimKey, kindOf } from './names.js'; +import type { BuiltIn } from '../trait/key.js'; + +const builtIn: ReadonlySet = new Set(["type", "derived", "within", "reference", "reduce", "thread", "transfer"]); + +/** Whether `name` is a trait: a built-in one's string, or a registered trait's symbol. */ +export const isTrait = (name: unknown): boolean => builtIn.has(name) || kindOf(name) === "trait"; + +/** Registers a trait's key; registering it again is accepted. Throws for a key that isn't a plain symbol, or one of another kind. */ +export const addTrait = (key: unknown): void => { + if (kindOf(key) !== "trait") claimKey(key, "trait"); +}; diff --git a/packages/ecs/src/interface/registry/written.ts b/packages/ecs/src/interface/registry/written.ts new file mode 100644 index 000000000..e6661f343 --- /dev/null +++ b/packages/ecs/src/interface/registry/written.ts @@ -0,0 +1,14 @@ +import { clock } from '../resources/clock.js'; +import { labelOf } from './names.js'; + +/** Resources only the world writes. */ +const worldWritten: ReadonlySet = new Set([clock]); + +/** Throws if `writes` lists a resource only the world writes. */ +export const checkWorldWritten = (what: string, writes: readonly (symbol)[] | undefined): void => { + const found = writes?.find(key => worldWritten.has(key)); + if (found !== undefined) throw new Error(`${what} writes "${labelOf(found)}", which only the world writes.`); +}; + +/** Whether only the world writes a resource. */ +export const isWorldWritten = (key: symbol): boolean => worldWritten.has(key); diff --git a/packages/ecs/src/interface/report/components.ts b/packages/ecs/src/interface/report/components.ts new file mode 100644 index 000000000..befc83cdb --- /dev/null +++ b/packages/ecs/src/interface/report/components.ts @@ -0,0 +1,69 @@ +import { register } from '../component/register.js'; +import type { Failure, Warning } from './failure.js'; + +/** + * What every run and tick reports, all structs, so every thread reads it: `number`, the run's own, every run and tick + * taking the next in the order they ran; `elapsed`, milliseconds from its start to its sync point; `systems`, how many + * ran; `workers`, how many pool workers took part; `joined`, how many joined threads ran its systems; `main`, whether + * any ran on the main thread; `gpu`, whether it submitted GPU work; `dropped`, commands that did nothing, their entity + * gone at the sync point. + */ +export const run = Symbol("Report/run"); +/** + * What went wrong, on a report only when something did: `failures`, systems, finalizers, GPU work, threads and unique + * indexes that failed, in the order they ran or were found; `skipped`, the labels of systems that didn't run, those in + * tiers after one that failed and those of a joined thread that's gone; `warnings`, what ran but slowed the run. + */ +export const errors = Symbol("Report/errors"); + +declare module '@adobe/ecs' { + export interface Components { + [run]: { + type: { + readonly number: number; + readonly elapsed: number; + readonly systems: number; + readonly workers: number; + readonly joined: number; + readonly main: boolean; + readonly gpu: boolean; + readonly dropped: number; + }; + }; + [errors]: { type: { readonly failures: readonly Failure[]; readonly skipped: readonly string[]; readonly warnings: readonly Warning[] } }; + } +} + +const text = { type: "string" } as const; +const count = { type: "integer", minimum: 0 } as const; + +register(run, { + type: { + type: "object", + properties: { + number: count, elapsed: { type: "number", minimum: 0 }, systems: count, workers: count, joined: count, + main: { type: "boolean" }, gpu: { type: "boolean" }, dropped: count, + }, + }, +}); +register(errors, { + type: { + type: "object", + properties: { + failures: { + type: "array", + items: { + type: "object", + properties: { + kind: { anyOf: [{ const: "system" }, { const: "finalizer" }, { const: "gpu" }, { const: "thread" }, { const: "index" }] }, + name: text, + message: text, + stack: { anyOf: [text, { type: "null" }] }, + }, + }, + }, + skipped: { type: "array", items: text }, + warnings: { type: "array", items: { type: "object", properties: { kind: { const: "blocking" }, name: text, message: text } } }, + }, + }, +}); diff --git a/packages/ecs/src/interface/report/failure.ts b/packages/ecs/src/interface/report/failure.ts new file mode 100644 index 000000000..2168e7902 --- /dev/null +++ b/packages/ecs/src/interface/report/failure.ts @@ -0,0 +1,26 @@ +/** What failed in a run: a system, finalizer, GPU work, thread or unique index. */ +export type Failure = { + /** + * What failed: a system or finalizer that threw, GPU work, a system whose thread is gone, which didn't run, or a + * unique index found holding two entities for one key, of which lookups give the one with the lower entity index; + * later tiers still run after a `"thread"` or `"index"` failure. + */ + readonly kind: "system" | "finalizer" | "gpu" | "thread" | "index"; + /** + * The label of the system or finalizer that failed; for GPU work, of the system whose run submitted it; for an + * index, the index's, with the key and both entities in `message`. Labels are for display: a system's, + * finalizer's or index's is its name while no other registered system, rule, finalizer or index has it, else its + * name with its id, or numbered without one. + */ + readonly name: string; + /** An Error's message, or String() of anything else thrown. */ + readonly message: string; + /** The thrown Error's `stack`, as the JavaScript engine formats it, for logs and display; null when it had none. */ + readonly stack: string | null; +}; + +/** + * What ran but slowed a run. `"blocking"`: a system that runs on a thread a joined thread fills and isn't terminal, so + * its tier waited for that thread, by the system's label. + */ +export type Warning = { readonly kind: "blocking"; readonly name: string; readonly message: string }; diff --git a/packages/ecs/src/interface/report/index.ts b/packages/ecs/src/interface/report/index.ts new file mode 100644 index 000000000..a31bf1c20 --- /dev/null +++ b/packages/ecs/src/interface/report/index.ts @@ -0,0 +1,8 @@ +import { create as createArchetype } from '../archetype/create.js'; +import { run } from './components.js'; + +export { errors, run } from './components.js'; +export type { Failure, Warning } from './failure.js'; + +/** A report's archetype: the world gives every run and tick one, which gains `errors` when something went wrong. */ +export const Archetype = createArchetype([run]); diff --git a/packages/ecs/src/interface/report/report.ts b/packages/ecs/src/interface/report/report.ts new file mode 100644 index 000000000..f38b3c882 --- /dev/null +++ b/packages/ecs/src/interface/report/report.ts @@ -0,0 +1,11 @@ +import type { Component } from '../component/component.js'; +import type { errors, run } from './components.js'; + +/** + * A report's values, as rows and lookups give them. After the sync point of each run and tick, before its observer + * systems, the world writes its report, an entity it keeps for recent runs: `errors` is there only when something went + * wrong. Once a run resolves, its report is there. Queries that require no component don't see reports. + */ +export type Report = { readonly [run]: Component.Value; readonly [errors]?: Component.Value }; + +export * as Report from './index.js'; diff --git a/packages/ecs/src/interface/report/report.type-test.ts b/packages/ecs/src/interface/report/report.type-test.ts new file mode 100644 index 000000000..28262aedd --- /dev/null +++ b/packages/ecs/src/interface/report/report.type-test.ts @@ -0,0 +1,63 @@ +import type { Component } from '../component/component.js'; +import * as resources from '../resources/index.js'; +import { System } from '../system/system.js'; +import { main, watchTime } from '../system/system.type-test.js'; +import type { World } from '../world/world.js'; +import { Report } from './report.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +declare const world: World; + +// Each key is a component, declared under its namespaced computed key. +type Run = { + readonly number: number; + readonly elapsed: number; + readonly systems: number; + readonly workers: number; + readonly joined: number; + readonly main: boolean; + readonly gpu: boolean; + readonly dropped: number; +}; +type Errors = { readonly failures: readonly Report.Failure[]; readonly skipped: readonly string[]; readonly warnings: readonly Report.Warning[] }; +check, Run>>(); +check, Errors>>(); +check>(); + +// A failure and a warning, as data. +declare const failure: Report.Failure; +check>(); +check>(); +check>(); + +// A report's values: errors only when something went wrong. +check>(); + +// An observer system follows reports by their components. +const logged: Report.Failure[] = []; +export const logger = System.create({ observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { + const number: number = row[Report.run].number; + for (const each of row[Report.errors].failures) logged.push(each); + // @ts-expect-error a report's lists are read-only + row[Report.errors].failures.push(logged[0]); + void number; +}); +export const timings = System.create({ observer: true, components: { read: [Report.run], changed: [Report.run] } }, row => { + const { elapsed, systems, workers, joined, main: onMain, gpu, dropped } = row[Report.run]; + void [elapsed, systems, workers, joined, onMain, gpu, dropped]; +}); + +// A run resolves to its number; a rejected run rejects. +const ran: Promise = world.run(main); +const observed: Promise = world.run(watchTime); +void [ran, observed]; +// @ts-expect-error no index by run: indexes key on whole components +void Report.byRun; +// @ts-expect-error no failures component: they're in errors +void Report.failures; +// @ts-expect-error no World.Report: a report is entities +export type Reported = World.Report; +// @ts-expect-error no report resource: reports are entities +void resources.report; diff --git a/packages/ecs/src/interface/resource/condition.ts b/packages/ecs/src/interface/resource/condition.ts new file mode 100644 index 000000000..daeb02603 --- /dev/null +++ b/packages/ecs/src/interface/resource/condition.ts @@ -0,0 +1,8 @@ +import type { Key } from "./key.js"; +import type { Value } from './value.js'; + +/** The resources a system can run on the condition of: their type allows `null` or `false`. */ +export type Condition = { [K in Key]: null extends Value ? K : false extends Value ? K : never }[Key]; + +/** A condition's value while `is` holds: neither `null` nor `false`. */ +export type Holding = Exclude, null | false>; diff --git a/packages/ecs/src/interface/resource/declaration.ts b/packages/ecs/src/interface/resource/declaration.ts new file mode 100644 index 000000000..47a49d83b --- /dev/null +++ b/packages/ecs/src/interface/resource/declaration.ts @@ -0,0 +1,29 @@ +import type { Describing, Starting } from "../component/declaration.js"; +import type { IsData } from "../schema/is-data.js"; +import type { Schema } from "../schema/schema.js"; +import type { Key as TraitKey } from "../trait/key.js"; +import type { Required } from "../trait/required.js"; +import type { Traits } from "../trait/types.js"; +import type { SymbolKeys } from "../trait/symbol-keys.js"; +import type { Key } from "./key.js"; +import type { Resources } from "./types.js"; + +/** + * A resource always has a value: its schema's `default` or the declaration's, of the declared type `T`, either left + * out only when `T` allows `null`. A type that isn't data has no schema, so only the declaration's. + */ +type TypePart = + IsData extends true + ? [S] extends [never] ? { readonly type: Schema } : Describing & (S extends { readonly default: unknown } ? { readonly default?: never } : Starting) + : { readonly type?: never } & Starting; + +/** As for components, with the value a resource starts at. */ +export type Declaration = + { readonly type?: S } + & TypePart + & { readonly [T in Exclude]: + T extends TraitKey ? (Resources[K][T] extends Traits[T] ? Resources[K][T] : never) : + never } + & { readonly [T in Exclude]?: never } + & Required + & SymbolKeys; diff --git a/packages/ecs/src/interface/resource/declaration.type-test.ts b/packages/ecs/src/interface/resource/declaration.type-test.ts new file mode 100644 index 000000000..ab4ff9a02 --- /dev/null +++ b/packages/ecs/src/interface/resource/declaration.type-test.ts @@ -0,0 +1,89 @@ +import { Resource } from './resource.js'; + +const gravity = Symbol("declarationTypeTest/gravity"); +const total = Symbol("declarationTypeTest/total"); +const names = Symbol("declarationTypeTest/names"); +const tags = Symbol("declarationTypeTest/tags"); +const selected = Symbol("declarationTypeTest/selected"); +const device = Symbol("declarationTypeTest/device"); +const page = Symbol("declarationTypeTest/page"); +const handles = Symbol("declarationTypeTest/handles"); +const candidates = Symbol("declarationTypeTest/candidates"); +const misnamed = Symbol("declarationTypeTest/misnamed"); + +type Box = { readonly x: number; readonly y: number }; +type Candidate = Box & { readonly id: number }; + +declare module '@adobe/ecs' { + export interface Resources { + [gravity]: { type: number }; + [total]: { type: number; reduce: "+" }; + [names]: { type: ReadonlyMap }; + [tags]: { type: ReadonlySet }; + [selected]: { type: string | null }; + [device]: { type: GPUDevice | null }; + [page]: { type: Document; thread: "main" }; + [handles]: { type: Map }; + [candidates]: { type: readonly Candidate[] }; + [misnamed]: { type: readonly Candidate[] }; + } +} + +const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; + +// A resource starts at its schema's default or its declaration's. +Resource.register(gravity, { type: { type: "number", default: -9.8 } }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(names, { type: namesSchema, default: new Map() }); +Resource.register(tags, { type: { type: "set", items: { type: "string" } }, default: new Set(["a"]) }); +// A type that allows null starts at null without one. +Resource.register(selected, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); +Resource.register(device, {}); +Resource.register(page, { default: document, thread: "main" }); +Resource.register(handles, { default: new Map() }); + +// A schema describes exactly the declared type. +// @ts-expect-error a string isn't a number +Resource.register(gravity, { type: { type: "string", default: "" } }); +// @ts-expect-error a map's values are numbers +Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "string" } }, default: new Map() }); + +// An intersection describes the object it equals. +const number = { type: "number" } as const; +Resource.register(candidates, { type: { type: "array", items: { type: "object", properties: { id: number, x: number, y: number }, required: ["id", "x", "y"] }, default: [] } }); +// @ts-expect-error a member's name differs +Resource.register(misnamed, { type: { type: "array", items: { type: "object", properties: { key: number, x: number, y: number }, required: ["key", "x", "y"] }, default: [] } }); + +// Every declared trait is registered, with its declared value, and nothing undeclared is. +// @ts-expect-error a declared trait is missing +Resource.register(total, { type: { type: "number", default: 0 } }); +// @ts-expect-error an undeclared trait is given +Resource.register(gravity, { type: { type: "number", default: 0 }, reduce: "+" }); +// @ts-expect-error a trait's value differs from the declared one +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "max" }); +// A declaration held in a constant gets no excess property check, so the types name each undeclared trait. +const reduced = { type: { type: "number", default: 0 }, reduce: "+" } as const; +// @ts-expect-error an undeclared trait is given +Resource.register(gravity, reduced); + +// A type that's data is registered with its schema; one that isn't data has none. +// @ts-expect-error a map of strings to numbers is data, so it has a schema +Resource.register(names, { default: new Map() }); +// @ts-expect-error a string is data, so it has a schema, though it may start at null +Resource.register(selected, {}); +// @ts-expect-error a value that isn't data has no schema +Resource.register(handles, { type: { type: "object", properties: {} }, default: new Map() }); + +// A default is a value of the declared type, given once. +// @ts-expect-error the schema's default isn't a number +Resource.register(gravity, { type: { type: "number", default: "down" } }); +// @ts-expect-error the declaration's default isn't a map of strings to numbers +Resource.register(names, { type: namesSchema, default: new Set() }); +// @ts-expect-error a default isn't a Map +Resource.register(handles, { default: "map" }); +// @ts-expect-error a type without null starts at a default +Resource.register(gravity, { type: { type: "number" } }); +// @ts-expect-error a value that isn't data, without null, starts at a default +Resource.register(handles, {}); +// @ts-expect-error a schema's default and the declaration's can't both be given +Resource.register(gravity, { type: { type: "number", default: 0 }, default: 1 }); diff --git a/packages/ecs/src/interface/resource/index.ts b/packages/ecs/src/interface/resource/index.ts new file mode 100644 index 000000000..b8017c664 --- /dev/null +++ b/packages/ecs/src/interface/resource/index.ts @@ -0,0 +1,3 @@ +export * from './register.js'; +export type { Key } from './key.js'; +export type { Value } from './value.js'; diff --git a/packages/ecs/src/interface/resource/key.ts b/packages/ecs/src/interface/resource/key.ts new file mode 100644 index 000000000..0a39b5f0d --- /dev/null +++ b/packages/ecs/src/interface/resource/key.ts @@ -0,0 +1,3 @@ +import type { Resources } from './types.js'; + +export type Key = keyof Resources; diff --git a/packages/ecs/src/interface/resource/reduction.ts b/packages/ecs/src/interface/resource/reduction.ts new file mode 100644 index 000000000..c42c2d6b2 --- /dev/null +++ b/packages/ecs/src/interface/resource/reduction.ts @@ -0,0 +1,11 @@ +import type { Key } from "./key.js"; +import type { Resources } from './types.js'; + +/** How a reduction combines values, leaf by leaf: a sum, a product, the least or greatest, or a bitwise fold. */ +export type Reduction = "+" | "*" | "min" | "max" | "&" | "|" | "^"; + +/** The resources declared with a `reduce` trait, which systems contribute to and nothing writes. */ +export type Reduced = { [K in Key]: Resources[K] extends { readonly reduce: Reduction } ? K : never }[Key]; + +/** The operator resource `K` is reduced with. */ +export type OperatorOf = Resources[K] extends { readonly reduce: infer R } ? R : never; diff --git a/packages/ecs/src/interface/resource/register.test.ts b/packages/ecs/src/interface/resource/register.test.ts new file mode 100644 index 000000000..be43b6632 --- /dev/null +++ b/packages/ecs/src/interface/resource/register.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../component/component.js"; +import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; +import { always, cadence, clock, gpuDevice, wake, workers } from "../resources/index.js"; +import { Resource } from "./resource.js"; + +const time = Symbol("typesResourceTest/time"); +const symbol = Symbol("typesResourceTest/symbol"); +const shared = Symbol("typesResourceTest/shared"); +const cache = Symbol("typesResourceTest/cache"); +const port = Symbol("typesResourceTest/port"); +const total = Symbol("typesResourceTest/total"); +const flags = Symbol("typesResourceTest/flags"); +const names = Symbol("typesResourceTest/names"); +const tags = Symbol("typesResourceTest/tags"); + +declare module "@adobe/ecs" { + export interface Resources { + [time]: { type: number }; + [symbol]: { type: boolean }; + [shared]: { type: true }; + [cache]: { type: Map; thread: "main" }; + [port]: { type: MessagePort | null; transfer: true }; + [total]: { type: number; reduce: "+" }; + [flags]: { type: boolean; reduce: "|" }; + [names]: { type: ReadonlyMap }; + [tags]: { type: ReadonlySet }; + } + export interface Components { + [shared]: { type: true }; + } +} + +Resource.register(time, { type: { type: "number", default: 0 } }); +Resource.register(symbol, { type: { type: "boolean", default: false } }); +// @ts-expect-error a key declared as both a component and a resource can't be registered as either +Component.register(shared, { type: { const: true } }); +Resource.register(cache, { default: new Map(), thread: "main" }); +Resource.register(port, { transfer: true }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(flags, { type: { type: "boolean", default: false }, reduce: "|" }); +const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; +Resource.register(names, { type: namesSchema, default: new Map([["a", 1]]) }); +Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } }); + +const untyped: (...parameters: never[]) => unknown = Resource.register; + +/** Registers as an untyped caller would, bypassing the types' checks. */ +const register = (key: unknown, declaration: object): void => { + Reflect.apply(untyped, undefined, [key, declaration]); +}; + +describe("Resource.register", () => { + it("records the declaration by its symbol, which needs no description", () => { + expect(declarationOf(time)).toEqual({ type: { type: "number", default: 0 } }); + expect(declarationOf(symbol)).toEqual({ type: { type: "boolean", default: false } }); + expect(kindOf(symbol)).toBe("resource"); + const anonymous = Symbol(); + register(anonymous, { type: { type: "boolean", default: false } }); + expect(kindOf(anonymous)).toBe("resource"); + // Another symbol with the same description is another key. + expect(() => register(Symbol("typesResourceTest/symbol"), { type: { type: "number", default: 0 } })).not.toThrow(); + expect(() => register("typesResourceTest/text", { type: { type: "number", default: 0 } })).toThrow("a key is a plain Symbol(...), not a string."); + }); + it("accepts the same declaration again, and rejects a different one or one also declared as a component", () => { + expect(() => Resource.register(time, { type: { type: "number", default: 0 } })).not.toThrow(); + expect(() => Resource.register(time, { type: { type: "number", default: 1 } })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); + // @ts-expect-error a key declared as both a component and a resource can't be registered as either + expect(() => Resource.register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); + }); + it("takes a resource's default from its declaration or its schema, never both, and compares sets and maps by their entries", () => { + expect(declarationOf(names)).toEqual({ type: namesSchema, default: new Map([["a", 1]]) }); + expect(() => Resource.register(names, { type: namesSchema, default: new Map([["a", 1]]) })).not.toThrow(); + expect(() => Resource.register(names, { type: namesSchema, default: new Map([["a", 2]]) })).toThrow("with another declaration"); + expect(() => Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } })).not.toThrow(); + expect(() => Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["y"]) } })).toThrow("with another declaration"); + expect(() => register(Symbol("typesResourceTest/twice"), { type: { type: "number", default: 0 }, default: 1 })).toThrow("its default is given twice"); + expect(threadOfDeclaration(declarationOf(names) ?? {})).toBe("world"); + }); + it("declares gpuDevice on \"world\", and the loop's state as resources", () => { + expect(declarationOf(gpuDevice)).toEqual({}); + expect(threadOfDeclaration(declarationOf(gpuDevice) ?? {})).toBe("world"); + expect(declarationOf(workers)).toEqual({ type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); + // null isn't a struct, so workers is an object resource on "world". + expect(threadOfDeclaration(declarationOf(workers) ?? {})).toBe("world"); + const defaults = [cadence, always, wake, clock].map(name => { + const type = declarationOf(name)?.type; + return type !== undefined && "default" in type ? type.default : undefined; + }); + expect(defaults).toEqual([{ kind: "frame" }, false, Infinity, { now: 0, delta: 0, tick: 0 }]); + expect(declarationOf(wake)?.reduce).toBe("min"); + expect([cadence, always, wake, clock].map(name => threadOfDeclaration(declarationOf(name) ?? {}))).toEqual(["world", null, null, null]); + }); + it("checks thread and transfer as far as values tell", () => { + expect(declarationOf(cache)).toEqual({ default: new Map(), thread: "main" }); + expect(threadOfDeclaration(declarationOf(cache) ?? {})).toBe("main"); + expect(declarationOf(port)).toEqual({ transfer: true }); + expect(() => register(Symbol("typesResourceTest/buffer"), { default: new ArrayBuffer(8), transfer: true })).not.toThrow(); + expect(() => register(Symbol("typesResourceTest/map"), { default: new Map(), transfer: true })).toThrow("its value isn't Transferable"); + expect(() => register(Symbol("typesResourceTest/count"), { type: { type: "number", default: 0 }, thread: "main" })).toThrow("a struct lives in shared memory"); + }); + it("checks reductions", () => { + expect(() => register(Symbol("typesResourceTest/badOperator"), { type: { type: "number", default: 0 }, reduce: "-" })).toThrow('reduce is one of "+"'); + expect(() => register(Symbol("typesResourceTest/sumFlags"), { type: { type: "boolean", default: false }, reduce: "+" })).toThrow('booleans reduce with "&", "|" or "^"'); + expect(() => register(Symbol("typesResourceTest/text"), { type: { type: "string", default: "" }, reduce: "max" })).toThrow("a reduced resource is a struct"); + }); +}); diff --git a/packages/ecs/src/interface/resource/register.ts b/packages/ecs/src/interface/resource/register.ts new file mode 100644 index 000000000..4a34018f9 --- /dev/null +++ b/packages/ecs/src/interface/resource/register.ts @@ -0,0 +1,18 @@ +import type { Component } from "../component/component.js"; +import { createRegistry, type AnyDeclaration } from "../registry/declarations.js"; +import type { Schema } from "../schema/schema.js"; +import type { Key as TraitKey } from "../trait/key.js"; +import type { Declaration } from "./declaration.js"; +import type { Key } from "./key.js"; + +const registry = createRegistry("resource"); + +/** + * Registers the resource `key`, a plain `Symbol(...)` whose traits `Resources` declares, as `Component.register` + * does: `Resource.register(gravity, { type: { type: "number", default: -9.8 } })`. A resource always has a value: its + * schema's `default` or the declaration's, either left out only when its type allows `null`, which it then starts at. + */ +export function register & symbol, const S extends Schema & { readonly default?: unknown } = never>(key: K, declaration: Declaration): void; +export function register(key: unknown, declaration: AnyDeclaration): void { + registry(key, declaration); +} diff --git a/packages/ecs/src/interface/resource/resource.ts b/packages/ecs/src/interface/resource/resource.ts new file mode 100644 index 000000000..8a5b9d49e --- /dev/null +++ b/packages/ecs/src/interface/resource/resource.ts @@ -0,0 +1,4 @@ + +export type Resource = never; + +export * as Resource from './index.js'; diff --git a/packages/ecs/src/interface/resource/types.ts b/packages/ecs/src/interface/resource/types.ts new file mode 100644 index 000000000..4469c5523 --- /dev/null +++ b/packages/ecs/src/interface/resource/types.ts @@ -0,0 +1,3 @@ +/** Each resource's traits, `type` included, by augmentation: `[time]: { type: number }`. Registration must give exactly these. */ +export interface Resources { +} diff --git a/packages/ecs/src/interface/resource/value.ts b/packages/ecs/src/interface/resource/value.ts new file mode 100644 index 000000000..7561a877a --- /dev/null +++ b/packages/ecs/src/interface/resource/value.ts @@ -0,0 +1,20 @@ +import type { ThreadOf } from '../thread/of.js'; +import type { Key } from "./key.js"; +import type { Resources } from "./types.js"; + +/** Every resource's value type, computed once so `Value` is a lookup. */ +type ValueTypes = { readonly [K in Key]: Resources[K] extends { readonly type: infer T } ? T : never }; + +/** The value type of resource `K`. */ +export type Value = ValueTypes[K]; + +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +/** Resource `K`'s value as reads return it: read-only, with branded primitives such as `Entity` as they are. */ +export type ReadonlyValue = Value extends Primitive ? Value : Readonly>; + +/** Every resource's thread, computed once so `ThreadOf` is a lookup. */ +type Threads = { readonly [K in Key]: ThreadOf> }; + +/** The thread resource `K` lives on, or `never` for a struct, which lives in shared memory. */ +export type ThreadOfResource = Threads[K]; diff --git a/packages/ecs/src/interface/resource/written.ts b/packages/ecs/src/interface/resource/written.ts new file mode 100644 index 000000000..1e97a8291 --- /dev/null +++ b/packages/ecs/src/interface/resource/written.ts @@ -0,0 +1,4 @@ +import type { clock } from '../resources/clock.js'; + +/** Resources only the world writes: systems and finalizers read them, and worlds don't start them at other values. */ +export type WorldWritten = typeof clock; diff --git a/packages/ecs/src/interface/resources/always.ts b/packages/ecs/src/interface/resources/always.ts new file mode 100644 index 000000000..b64f0d158 --- /dev/null +++ b/packages/ecs/src/interface/resources/always.ts @@ -0,0 +1,12 @@ +import { Resource } from '../resource/resource.js'; + +/** Whether every beat of the cadence runs a tick, not only beats where `wake` is due or a run arrived. False by default. */ +export const always = Symbol("always"); + +declare module '@adobe/ecs' { + export interface Resources { + [always]: { type: boolean }; + } +} + +Resource.register(always, { type: { type: "boolean", default: false } }); diff --git a/packages/ecs/src/interface/resources/cadence.ts b/packages/ecs/src/interface/resources/cadence.ts new file mode 100644 index 000000000..f90f750e1 --- /dev/null +++ b/packages/ecs/src/interface/resources/cadence.ts @@ -0,0 +1,27 @@ +import { Resource } from '../resource/resource.js'; + +/** The cadence's value, for the implementation: `Resource.Value`. */ +export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; readonly step: number; readonly maxSteps?: number }; + +/** + * When the world ticks: once per animation frame, `{ kind: "frame" }`, the default, or every `step` milliseconds, + * `{ kind: "fixed", step }`. Under `fixed`, a beat that's behind runs at most `maxSteps` ticks, 4 by default, and drops + * the rest of the debt; each tick's `clock.delta` is `step`. + */ +export const cadence = Symbol("cadence"); + +declare module '@adobe/ecs' { + export interface Resources { + [cadence]: { type: Cadence }; + } +} + +Resource.register(cadence, { + type: { + anyOf: [ + { type: "object", properties: { kind: { const: "frame" } } }, + { type: "object", properties: { kind: { const: "fixed" }, step: { type: "number", minimum: 0 }, maxSteps: { type: "integer", minimum: 1 } }, required: ["kind", "step"] }, + ], + default: { kind: "frame" }, + }, +}); diff --git a/packages/ecs/src/interface/resources/clock.ts b/packages/ecs/src/interface/resources/clock.ts new file mode 100644 index 000000000..55710affa --- /dev/null +++ b/packages/ecs/src/interface/resources/clock.ts @@ -0,0 +1,17 @@ +import { Resource } from '../resource/resource.js'; + +/** + * The current tick, written only by the world before each tick: `now`, the beat's time in milliseconds, `delta`, the + * milliseconds since the previous tick (`step` under a fixed cadence), and `tick`, how many ticks ran before. + */ +export const clock = Symbol("clock"); + +declare module '@adobe/ecs' { + export interface Resources { + [clock]: { type: { readonly now: number; readonly delta: number; readonly tick: number } }; + } +} + +Resource.register(clock, { + type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } }, default: { now: 0, delta: 0, tick: 0 } }, +}); diff --git a/packages/ecs/src/interface/resources/gpu-device.ts b/packages/ecs/src/interface/resources/gpu-device.ts new file mode 100644 index 000000000..538c2040e --- /dev/null +++ b/packages/ecs/src/interface/resources/gpu-device.ts @@ -0,0 +1,16 @@ +import { Resource } from '../resource/resource.js'; + +/** + * The device the world's GPU storage lives on, for both `kernel: "gpu"` kernels and render systems, or null for a world + * without one. It lives on `"world"`, with the world. The world moves its GPU storage to whatever this is set to, and + * never destroys a device. + */ +export const gpuDevice = Symbol("gpuDevice"); + +declare module '@adobe/ecs' { + export interface Resources { + [gpuDevice]: { type: GPUDevice | null }; + } +} + +Resource.register(gpuDevice, {}); diff --git a/packages/ecs/src/interface/resources/index.ts b/packages/ecs/src/interface/resources/index.ts new file mode 100644 index 000000000..a74de9d30 --- /dev/null +++ b/packages/ecs/src/interface/resources/index.ts @@ -0,0 +1,6 @@ +export * from './always.js'; +export { cadence } from './cadence.js'; +export * from './clock.js'; +export * from './gpu-device.js'; +export * from './wake.js'; +export * from './workers.js'; diff --git a/packages/ecs/src/interface/resources/wake.ts b/packages/ecs/src/interface/resources/wake.ts new file mode 100644 index 000000000..fcd1a89e4 --- /dev/null +++ b/packages/ecs/src/interface/resources/wake.ts @@ -0,0 +1,15 @@ +import { Resource } from '../resource/resource.js'; + +/** + * The earliest `clock.now`, in milliseconds, at which a system asked for the next tick, by contributing to it; Infinity + * for none. The first beat at or after it runs a tick. + */ +export const wake = Symbol("wake"); + +declare module '@adobe/ecs' { + export interface Resources { + [wake]: { type: number; reduce: "min" }; + } +} + +Resource.register(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); diff --git a/packages/ecs/src/interface/resources/workers.ts b/packages/ecs/src/interface/resources/workers.ts new file mode 100644 index 000000000..bac048934 --- /dev/null +++ b/packages/ecs/src/interface/resources/workers.ts @@ -0,0 +1,12 @@ +import { Resource } from '../resource/resource.js'; + +/** The kernel pool's size, 0 for none, or `null`, the default, for the platform's choice. The pool follows it at sync points. */ +export const workers = Symbol("workers"); + +declare module '@adobe/ecs' { + export interface Resources { + [workers]: { type: number | null }; + } +} + +Resource.register(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); diff --git a/packages/ecs/src/interface/rule/create.ts b/packages/ecs/src/interface/rule/create.ts new file mode 100644 index 000000000..75808cf82 --- /dev/null +++ b/packages/ecs/src/interface/rule/create.ts @@ -0,0 +1,25 @@ +import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { takeIdOfDeclaration } from '../registry/ids.js'; +import { nameOf } from '../registry/items.js'; +import { addRule } from '../registry/program.js'; +import type { Rule as Created } from './created.js'; +import type { Declaration } from './declaration.js'; + +/** + * Declares a rule, which applies in every world, and returns it: identified by that value, labeled by its name. Throws + * if it matches on a key that isn't a registered component, derives a component that isn't derived, or derives in a + * cycle through a negation. + */ +export const create = (declaration: D): { readonly name: string } => { + const id = takeIdOfDeclaration(declaration); + const name = nameOf(declaration.name, id, "rule"); + const { components, derive } = declaration; + for (const key of [...components.has ?? [], ...components.not ?? [], ...derive]) { + if (kindOf(key) !== "component") throw new Error(`Rule "${name}" uses "${labelOf(key)}", which isn't a registered component.`); + } + for (const key of derive) { + if (declarationOf(key)?.derived !== true) throw new Error(`Rule "${name}" derives "${labelOf(key)}", which isn't declared derived.`); + } + const rule: Created = { kind: "rule", name, declaration }; + return addRule(rule, id); +}; diff --git a/packages/ecs/src/interface/rule/created.ts b/packages/ecs/src/interface/rule/created.ts new file mode 100644 index 000000000..f3752f11f --- /dev/null +++ b/packages/ecs/src/interface/rule/created.ts @@ -0,0 +1,4 @@ +import type { Declaration } from './declaration.js'; + +/** A rule: entities matching its components have the components it derives. */ +export type Rule = { readonly kind: "rule"; readonly name: string; readonly declaration: D }; diff --git a/packages/ecs/src/interface/rule/declaration.ts b/packages/ecs/src/interface/rule/declaration.ts new file mode 100644 index 000000000..ade032da1 --- /dev/null +++ b/packages/ecs/src/interface/rule/declaration.ts @@ -0,0 +1,11 @@ +import type { Component } from '../component/component.js'; +import type { Name } from './name.js'; +import type { Derived } from '../component/value.js'; + +export type Declaration = { + readonly name?: Name; + /** Which entities the rule matches, by the components they have and don't have. */ + readonly components: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + /** Components present on matching entities, while any rule deriving them matches. */ + readonly derive: readonly Derived[]; +}; diff --git a/packages/ecs/src/interface/rule/index.ts b/packages/ecs/src/interface/rule/index.ts new file mode 100644 index 000000000..8fbd8a7ef --- /dev/null +++ b/packages/ecs/src/interface/rule/index.ts @@ -0,0 +1 @@ +export * from './create.js'; diff --git a/packages/ecs/src/interface/rule/name.ts b/packages/ecs/src/interface/rule/name.ts new file mode 100644 index 000000000..50cbfa06d --- /dev/null +++ b/packages/ecs/src/interface/rule/name.ts @@ -0,0 +1,6 @@ +/** + * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so + * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without + * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. + */ +export type Name = string; diff --git a/packages/ecs/src/interface/rule/rule.ts b/packages/ecs/src/interface/rule/rule.ts new file mode 100644 index 000000000..3142a2d4d --- /dev/null +++ b/packages/ecs/src/interface/rule/rule.ts @@ -0,0 +1,3 @@ +export type Rule = never; + +export * as Rule from './index.js'; diff --git a/packages/ecs/src/interface/rule/rule.type-test.ts b/packages/ecs/src/interface/rule/rule.type-test.ts new file mode 100644 index 000000000..409abbda8 --- /dev/null +++ b/packages/ecs/src/interface/rule/rule.type-test.ts @@ -0,0 +1,117 @@ +import { Resource } from '../resource/resource.js'; +import { Component } from '../component/component.js'; +import { Entity } from '../entity/entity.js'; +import { Finalizer } from '../finalizer/finalizer.js'; +import { System } from '../system/system.js'; +import type { Create } from '../world/create.js'; +import type { World } from '../world/world.js'; +import { Rule } from './rule.js'; +import type { Writable } from '../component/value.js'; +import type { ChangeSet } from '../finalizer/body.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const source = Symbol("ruleTypeTest/source"); +const hidden = Symbol("ruleTypeTest/hidden"); +const slot = Symbol("ruleTypeTest/slot"); +const parent = Symbol("ruleTypeTest/parent"); +const time = Symbol("ruleTypeTest/time"); + +declare module '@adobe/ecs' { + export interface Components { + [source]: { type: number }; + [hidden]: { type: true }; + [slot]: { type: number; derived: true }; + [parent]: { type: Entity; reference: "owner" }; + } + export interface Resources { + [time]: { type: number }; + } +} +Component.register(source, { type: { type: "number" } }); +Component.register(hidden, { type: { const: true } }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Resource.register(time, { type: { type: "number", default: 0 } }); + +// Rules derive only derived components. +const slots = Rule.create({ name: "ruleTypeTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }); +// @ts-expect-error a rule derives only derived components +Rule.create({ name: "ruleTypeTest/slots", components: { has: [source] }, derive: [source] }); + +// A finalizer's context holds only what it declares, and its commands. +const other = Finalizer.create({ name: "ruleTypeTest/other", components: { read: [source] } }, (_, context) => { + // @ts-expect-error no resources declared + void context.resources; + // @ts-expect-error no lookups declared + void context.lookup; + void context.commands; +}); + +// Per-query finalizers read the stopping row; it's read-only. +Finalizer.create({ + name: "ruleTypeTest/release", + components: { read: [slot, parent], not: [hidden] }, + resources: { read: [time] }, + after: [other], +}, (row, { resources, commands }) => { + const value: number = row[slot]; + const target: Entity = row[parent]; + const now: number = resources[time]; + commands.despawn(target); + // @ts-expect-error a finalizer's row is read-only + row[slot] = 1; + // @ts-expect-error only declared reads are in the row + void row[source]; + // @ts-expect-error read resources are read-only + resources[time] = 0; + void value; void now; +}); +Finalizer.create({ + name: "ruleTypeTest/other", + components: {}, + // @ts-expect-error write already makes a resource readable + resources: { read: [time], write: [time] }, +}, () => {}); + +// Changes finalizers see only the essential components they watch. +Finalizer.changes({ name: "ruleTypeTest/save", watch: [source, parent] }, (changes, { commands }) => { + check>>(); + void commands; + for (const group of changes.groups) { + check>(); + for (const entity of group.entities) { + const before: number | null = changes.previous(entity, source); + const after: Entity | null = changes.next(entity, parent); + // @ts-expect-error only watched components + changes.next(entity, hidden); + void before; void after; + } + } +}); +// @ts-expect-error changes finalizers don't watch derived components +Finalizer.changes({ name: "ruleTypeTest/save", watch: [slot] }, () => {}); + +// References change only through commands, so systems can't write them. +System.create({ name: "ruleTypeTest/move", components: { read: [parent], write: [source] } }, row => { row[source] = row[parent]; }); +// @ts-expect-error a reference changes only through commands +System.create({ name: "ruleTypeTest/move", components: { write: [parent] } }, () => {}); +check, typeof source>>(); + +// A rule and a finalizer are the values their constructors return, labeled by their names, which needn't be unique. +check>>(); +check>(); +check>(); +const sameName: Finalizer = Finalizer.create({ name: "ruleTypeTest/other", components: { read: [source] } }, () => {}); +void sameName; + +// Rules and finalizers apply in every world. +declare const create: Create; +export const created: World = create(); + +// Names are optional for rules and finalizers too. +const unnamedRule = Rule.create({ components: { has: [source] }, derive: [slot] }); +const unnamedFinalizer: Finalizer = Finalizer.create({ components: { read: [source] } }, row => { void row[source]; }); +const unnamedChanges: Finalizer = Finalizer.changes({ watch: [source] }, changes => { void changes.groups; }); +void [unnamedRule, unnamedFinalizer, unnamedChanges]; diff --git a/packages/ecs/src/interface/schema/is-data.ts b/packages/ecs/src/interface/schema/is-data.ts new file mode 100644 index 000000000..84edc293b --- /dev/null +++ b/packages/ecs/src/interface/schema/is-data.ts @@ -0,0 +1,25 @@ +type Method = (...parameters: never[]) => unknown; + +/** How deep `NotData` looks into members: deeper ones count as data, so recursive types stay finite. */ +type Depth = [never, 0, 1, 2, 3, 4]; + +/** + * True for a value that isn't data: a function, an object with a member that isn't data, or a `Set` or `Map`, which + * changes in place. A `ReadonlySet` or `ReadonlyMap` is data when what it holds is. Members are looked at five levels + * deep. + */ +type NotData = + [D] extends [never] ? false : + T extends string | number | boolean | bigint | symbol | null | undefined ? false : + T extends Method ? true : + T extends readonly (infer E)[] ? NotData : + T extends ReadonlyMap ? (T extends Map ? true : NotData | NotData) : + T extends ReadonlySet ? (T extends Set ? true : NotData) : + T extends object ? (true extends { [K in keyof T]-?: NotData }[keyof T] ? true : false) : + false; + +/** + * Whether a declared type is data a schema describes, so its registration gives that schema. A `Blob`, an + * `HTMLElement` or a `GPUDevice` isn't: its registration has no `type`, and the world stores the value as it is. + */ +export type IsData = true extends NotData ? false : true; diff --git a/packages/ecs/src/interface/schema/is-struct.ts b/packages/ecs/src/interface/schema/is-struct.ts new file mode 100644 index 000000000..5a506be10 --- /dev/null +++ b/packages/ecs/src/interface/schema/is-struct.ts @@ -0,0 +1,24 @@ +type Method = (...parameters: never[]) => unknown; + +/** Whether an object type has a method of its own, so it isn't data, looked at without recursing. */ +type HasMethod = true extends { [K in keyof T]-?: T[K] extends Method ? true : false }[keyof T] ? true : false; + +/** + * Whether a value is a struct: numbers, booleans, entities, fixed-length tuples and structs of those, so its schema has + * a fixed size. A struct lives in shared memory, where every thread reads it. + */ +export type IsStruct = + [T] extends [number | boolean] ? true : + [T] extends [Method] ? false : + [T] extends [readonly unknown[]] ? (number extends T["length"] ? false : IsStruct) : + [T] extends [object] ? (HasMethod extends true ? false : false extends { [K in keyof T]-?: IsStruct }[keyof T] ? false : true) : + false; + +/** Whether a value is a string, a union of string literals or a string or `null`. */ +export type IsString = [T] extends [null] ? false : [T] extends [string | null] ? true : false; + +/** + * Whether a value lives in shared memory, where every thread reads it: a struct or a string. Any other value is an + * object, which lives on one logical thread's heap, variable-length arrays included. + */ +export type IsShared = IsString extends true ? true : IsStruct; diff --git a/packages/ecs/src/interface/schema/schema.ts b/packages/ecs/src/interface/schema/schema.ts new file mode 100644 index 000000000..51e759c4b --- /dev/null +++ b/packages/ecs/src/interface/schema/schema.ts @@ -0,0 +1,168 @@ +import type { Entity } from '../entity/entity.js'; + +/** Any value representable in JSON. */ +type JSONValue = string | number | boolean | null | readonly JSONValue[] | { readonly [key: string]: JSONValue }; + +/** + * How a linear value is laid out in memory, and a promise that it's GPU compatible: registration throws if it isn't. + * - `storage`: WGSL storage-buffer layout, so a shader's matching `array` reads the column directly. + * - `vertex`: tightly packed, as WebGPU vertex buffers expect. + * Without it, the value is packed tightly for the CPU and may use any linear type. + */ +type GpuLayout = 'storage' | 'vertex'; + +interface ObjectSchema { + readonly type: 'object', + readonly properties: { readonly [P in K]: Schema }; + readonly required?: readonly K[]; + readonly layout?: GpuLayout; +} + +interface StringSchema { + readonly type: 'string'; + readonly minLength?: number; + readonly maxLength?: number; +} + +interface ArraySchema { + readonly type: 'array'; + readonly items: T; + readonly layout?: GpuLayout; + readonly minItems?: number; + readonly maxItems?: number; +} + +interface NumberSchema { + readonly type: 'number'; + readonly minimum?: number; + readonly maximum?: number; + readonly precision?: 1 | 2; +} + +/** An entity reference, typed `Entity`. Every entity-typed value uses it, so references can be found by schema. */ +interface EntitySchema { + readonly type: 'integer'; + readonly format: 'entity'; +} + +interface IntegerSchema { + readonly type: 'integer'; + readonly format?: never; + readonly minimum?: number; + readonly maximum?: number; +} + +interface BooleanSchema { + readonly type: 'boolean'; +} + +interface NullSchema { + readonly type: 'null'; +} + +/** A `ReadonlySet` of `items`' values. JSON has no set, so `'set'` isn't a JSON Schema type. */ +interface SetSchema { + readonly type: 'set'; + readonly items: T; +} + +/** A `ReadonlyMap` from `keys`' values to `values`' values. JSON has no map, so `'map'` isn't a JSON Schema type. */ +interface MapSchema { + readonly type: 'map'; + readonly keys: K; + readonly values: V; +} + +/** Matches exactly one JSON value of any shape. */ +interface ConstSchema { + /** The value alone fully describes the schema; combining it with `type` is redundant and disallowed. */ + readonly type?: never; + readonly const: JSONValue; +} + +interface AndSchema { + readonly allOf: readonly Schema[]; +} + +interface OrSchema { + readonly anyOf: readonly Schema[]; +} + +/** + * A JSON Schema of the forms the ECS reads, and two forms JSON lacks: `{ type: 'set', items }` and + * `{ type: 'map', keys, values }`. + */ +export type Schema = EntitySchema | ObjectSchema | StringSchema | ArraySchema | NumberSchema | IntegerSchema | BooleanSchema | NullSchema | SetSchema | MapSchema | ConstSchema | AndSchema | OrSchema; + +type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never; + +export namespace Schema { + /** Object properties are all required unless `required` is present, then only those listed are. */ + type ObjectToType = + T extends { required: readonly (infer R)[] } + ? Flat<{ readonly [P in Extract]: Schema.ToType } + & { readonly [P in Exclude]?: Schema.ToType }> + : { readonly [P in keyof T['properties']]: Schema.ToType }; + + /** + * One object type with the properties of an intersection, each as optional and read-only as it was. It's a + * conditional, so the result is an anonymous object type, which declarations emitted elsewhere can name. + */ + type Flat = T extends unknown ? { [P in keyof T]: T[P] } : never; + + /** An array whose `minItems` and `maxItems` are one literal length is a tuple of that length. */ + type ArrayToType = + T extends { readonly minItems: infer N extends number; readonly maxItems: infer M extends number } + ? number extends N ? readonly E[] : [N] extends [M] ? [M] extends [N] ? Tuple : readonly E[] : readonly E[] + : readonly E[]; + + type Tuple = + R["length"] extends N ? R : Tuple; + + /** + * A literal `const` narrows the type, a fixed-length array is a tuple, and a set or map is a `ReadonlySet` or + * `ReadonlyMap`; otherwise the structural type is used. + */ + export type ToType = + T extends AndSchema ? UnionToIntersection> : + T extends OrSchema ? Schema.ToType : + T extends { const: infer C } ? C : + T extends EntitySchema ? Entity : + T extends ObjectSchema ? ObjectToType : + T extends StringSchema ? string : + T extends ArraySchema ? ArrayToType> : + T extends SetSchema ? ReadonlySet> : + T extends MapSchema ? ReadonlyMap, Schema.ToType> : + T extends NumberSchema ? number : + T extends IntegerSchema ? number : + T extends BooleanSchema ? boolean : + T extends NullSchema ? null : + never; + + /** The schemas whose `ToType` is `T`; constrains a schema to describe a known type. */ + export type ForType = Schema & + ( + [T] extends [boolean] ? ForNonUnion : + // A union of strings, such as an augmentable set of names, may also be described as any string. + IsUnion extends true ? (OrSchema & { anyOf: readonly ForTypeEach[] }) | ([T] extends [string] ? StringSchema : never) : + ForNonUnion + ); + + type IsUnion = T extends unknown ? ([U] extends [T] ? false : true) : never; + type ForTypeEach = T extends unknown ? ForType : never; + + type ForNonUnion = + [T] extends [null] ? NullSchema : + [T] extends [true] ? ConstSchema & { const: T } : + [T] extends [false] ? ConstSchema & { const: T } : + [T] extends [boolean] ? BooleanSchema : + [T] extends [Entity] ? EntitySchema : + [T] extends [string] ? (string extends T ? StringSchema : ConstSchema & { const: T }) : + [T] extends [number] ? (NumberSchema | IntegerSchema) : + T extends readonly (infer U)[] ? ArraySchema & Schema> : + T extends ReadonlyMap ? MapSchema & Schema, ForType & Schema> : + T extends ReadonlySet ? SetSchema & Schema> : + // An optional property's schema describes its value when present. + T extends object ? ObjectSchema> & { properties: { [P in keyof T]-?: ForType> } } : + never; +} diff --git a/packages/ecs/src/interface/schema/schema.type-test.ts b/packages/ecs/src/interface/schema/schema.type-test.ts new file mode 100644 index 000000000..559a8b984 --- /dev/null +++ b/packages/ecs/src/interface/schema/schema.type-test.ts @@ -0,0 +1,153 @@ +import type { IsData } from "./is-data.js"; +import type { IsStruct } from "./is-struct.js"; +import type { Schema } from "./schema.js"; + +// Local copies of the standard compile-time assertion helpers. +type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +// primitives +check, string>>(); +check, number>>(); +check, number>>(); +check, boolean>>(); +check, null>>(); + +// const narrows +check, 'a'>>(); +check, true>>(); + +// standalone const accepts any JSON value +check, 'x'>>(); +check, readonly [1, 2]>>(); +const okConst: Schema = { const: { a: [1, 'b', null, { c: true }] } }; +// @ts-expect-error undefined is not a JSON value +const badConstUndefined: Schema = { const: undefined }; +// @ts-expect-error functions are not JSON values +const badConstFn: Schema = { const: () => 1 }; +// @ts-expect-error nested non-JSON values are rejected +const badConstNested: Schema = { type: 'object', properties: {}, const: { a: undefined } }; +// @ts-expect-error `type` + `const` is redundant, not a valid Schema +const badTypeAndConst: Schema = { type: 'boolean', const: true }; +// @ts-expect-error same when written `as const satisfies Schema` +const badSatisfies = { type: 'boolean', const: true } as const satisfies Schema; +void [badTypeAndConst, badSatisfies]; +void [okConst, badConstUndefined, badConstFn, badConstNested]; + +// arrays +check, readonly number[]>>(); + +// objects: all required when `required` is absent +check, + { readonly a: number; readonly b: string } +>>(); +// objects: only listed keys required when `required` is present +check, + { readonly a: number; readonly b?: string } +>>(); + +// combinators +check, string | null>>(); +check, + { readonly a: number } & { readonly b: string } +>>(); + +// ForType: accepts matching schemas, rejects mismatches +const okNumber: Schema.ForType = { type: 'integer' }; +const okNull: Schema.ForType = { type: 'null' }; +const okUnion: Schema.ForType = { anyOf: [{ type: 'string' }, { type: 'null' }] }; +const okArray: Schema.ForType = { type: 'array', items: { type: 'string' } }; +// @ts-expect-error string schema does not describe a number +const badNumber: Schema.ForType = { type: 'string' }; +// @ts-expect-error union members must describe a branch of the union +const badUnion: Schema.ForType = { anyOf: [{ type: 'number' }] }; +// @ts-expect-error array items must match +const badArray: Schema.ForType = { type: 'array', items: { type: 'number' } }; +void [okNumber, okNull, okUnion, okArray, badNumber, badUnion, badArray]; + +// every schema form is writable-free, so `as const` literals satisfy Schema +const asConstObject = { type: 'object', properties: { a: { type: 'number' } }, required: ['a'] } as const satisfies Schema; +const asConstAnyOf = { anyOf: [{ type: 'string' }, { type: 'null' }] } as const satisfies Schema; +const asConstAllOf = { allOf: [{ type: 'object', properties: {} }, { const: 1 }] } as const satisfies Schema; +const asConstArray = { type: 'array', items: { const: [1, { a: null }] } } as const satisfies Schema; +check['a'], number>>(); +check, string | null>>(); +void [asConstObject, asConstAnyOf, asConstAllOf, asConstArray]; + +// fields are readonly +declare const stringSchema: Extract; +// @ts-expect-error schemas are immutable +stringSchema.minLength = 1; +declare const objectSchema: Extract; +// @ts-expect-error properties are immutable +objectSchema.properties = {}; +// @ts-expect-error required is a readonly array +objectSchema.required?.push('x'); + +// ForType must not admit the redundant type + const form +// @ts-expect-error redundant +const badForTypeRedundant: Schema.ForType = { type: 'boolean', const: true }; +void badForTypeRedundant; + +// ForType boolean literals: only the matching bare const schema +const okTrue: Schema.ForType = { const: true }; +const okFalse: Schema.ForType = { const: false }; +const okBoolean: Schema.ForType = { type: 'boolean' }; +// @ts-expect-error const false does not describe true +const badTrue: Schema.ForType = { const: false }; +// @ts-expect-error const true does not describe false +const badFalse: Schema.ForType = { const: true }; +// @ts-expect-error redundant type + const for false +const badFalseRedundant: Schema.ForType = { type: 'boolean', const: false }; +// @ts-expect-error a const schema is too narrow for plain boolean +const badBoolean: Schema.ForType = { const: true }; +// @ts-expect-error type without const is too wide for the literal +const badTrueWide: Schema.ForType = { type: 'boolean' }; +void [okTrue, okFalse, okBoolean, badTrue, badFalse, badFalseRedundant, badBoolean, badTrueWide]; + +// fixed-length arrays are tuples; any other length is an array +check, readonly [number, number, number]>>(); +check, readonly number[]>>(); +check, readonly number[]>>(); + +// sets and maps, forms JSON lacks, are read-only +check, ReadonlySet>>(); +check, ReadonlyMap>>(); +check, { readonly tags: ReadonlySet<'a'> }>>(); +const okSet: Schema.ForType> = { type: 'set', items: { type: 'integer' } }; +const okMap: Schema.ForType> = { type: 'map', keys: { type: 'string' }, values: { type: 'boolean' } }; +// @ts-expect-error a set's items must match +const badSet: Schema.ForType> = { type: 'set', items: { type: 'string' } }; +// @ts-expect-error a map's values must match +const badMap: Schema.ForType> = { type: 'map', keys: { type: 'string' }, values: { type: 'number' } }; +// @ts-expect-error a set has items, not keys and values +const badSetForm: Schema = { type: 'set', keys: { type: 'string' }, values: { type: 'string' } }; +// @ts-expect-error a map has keys and values +const badMapForm: Schema = { type: 'map', items: { type: 'string' } }; +void [okSet, okMap, badSet, badMap, badSetForm, badMapForm]; + +// A ReadonlySet or ReadonlyMap of data is data; a Set or Map, which changes in place, isn't, nor one holding functions. +check>, true>>(); +check>, true>>(); +check void>>, false>>(); +check>, false>>(); +check>, false>>(); +// An object is data only when every member is: a record of handles isn't, however deep they are. +check, false>>(); +check, false>>(); +check, false>>(); +check, true>>(); +// A recursive data type is still data. +type Tree = { readonly value: number; readonly children: readonly Tree[] }; +check, true>>(); +// Neither is a struct: neither lives in shared memory. +check>, false>>(); +check>, false>>(); diff --git a/packages/ecs/src/interface/schema/schemas.type-test.ts b/packages/ecs/src/interface/schema/schemas.type-test.ts new file mode 100644 index 000000000..b42b5e5bd --- /dev/null +++ b/packages/ecs/src/interface/schema/schemas.type-test.ts @@ -0,0 +1,12 @@ +import type { Schema } from "./schema.js"; + +const ok: Schema.ForType = { const: true }; +const okObj: Schema.ForType<{ readonly x: number; readonly tags: readonly string[] }> = { + type: 'object', + properties: { x: { type: 'number' }, tags: { type: 'array', items: { type: 'string' } } }, +}; +// @ts-expect-error string schema does not describe a number +const bad: Schema.ForType = { type: 'string' }; +// @ts-expect-error const must match the type +const badConst: Schema.ForType = { const: false }; +void [ok, okObj, bad, badConst]; diff --git a/packages/ecs/src/interface/system/access.ts b/packages/ecs/src/interface/system/access.ts new file mode 100644 index 000000000..786a00559 --- /dev/null +++ b/packages/ecs/src/interface/system/access.ts @@ -0,0 +1,67 @@ +import type { Component } from '../component/component.js'; +import type { Resource } from '../resource/resource.js'; +import type { Writable } from '../component/value.js'; +import type { Condition } from '../resource/condition.js'; +import type { Reduced } from '../resource/reduction.js'; +import type { WorldWritten } from '../resource/written.js'; +import type { Index } from '../indexes/index-type.js'; + +/** + * Matches every component in `read`, `write`, `has`, `added` and `changed`, and excludes `not`. A component in `write` + * is also readable, so it's never listed in `read` as well. + */ +export type ComponentAccess = { + readonly read?: readonly Component.Key[]; + /** A component holding a `reference` changes only through commands, so it can't be written here. */ + readonly write?: readonly Writable[]; + readonly has?: readonly Component.Key[]; + readonly not?: readonly Component.Key[]; + /** Passes rows where any of these were added since the system last ran. */ + readonly added?: readonly Component.Key[]; + /** Passes rows where any of these may have changed since the system last ran. */ + readonly changed?: readonly Component.Key[]; +}; + +/** + * Read-only access to other entities: by entity, matching `read`, `has` and `not`, or through an index by key. A lookup + * by entity's `changed` names components it reads or has, whose changes its row's `changed` reports. An index lookup's + * `read` names components every entity the index holds has, its key parts or its `has`, and its `changed` the ones + * whose changes its lists' `changed` reports. Both report as a system's `changed` filter does; its system can't write a + * key part or what it reads. + */ +export type Lookup = + | Pick & { readonly changed?: readonly Component.Key[] } + | { readonly index: Index; readonly read?: readonly Component.Key[]; readonly changed?: readonly Component.Key[] }; + +/** + * A system's lookup. An index lookup's `changed: true` makes the index a change filter, as a resource in `changed` is: + * every row passes when an entity entered or left the index, or moved to another key or position in it, since the + * system last ran. A system whose only change filters these are runs only then, skipped before its body otherwise, as + * when a condition fails, keeping its `lastRun`. Its lists' `changed` then reports those changes alone. Only on the + * world's thread. + */ +export type SystemLookup = + | Lookup + | { readonly index: Index; readonly read?: readonly Component.Key[]; readonly changed: true }; + +/** + * The parent each row's values flow down from: the entity its `through` reference names, read before the row. Rows + * run parent first, so a parent's value is the one written earlier in the same run, and `read` may name components the + * system writes. + */ +export type Up = { readonly through: Component.With<"reference">; readonly read?: readonly Component.Key[] }; + +/** + * A resource in `write` is also readable, so it's never listed in `read` as well. Reduced resources are never written, + * nor are those only the world writes. + */ +export type ResourceAccess = { + readonly read?: readonly Resource.Key[]; + readonly write?: readonly Exclude[]; + /** Passes every row when any of these changed since the system last ran. */ + readonly changed?: readonly Resource.Key[]; + /** Runs only while each of these is neither `null` nor `false`, and reads it as such. Otherwise it's skipped, successfully. */ + readonly is?: readonly Condition[]; + /** Runs only while each of these is `null` or `false`. Otherwise it's skipped, successfully. */ + readonly not?: readonly Condition[]; +}; diff --git a/packages/ecs/src/interface/system/body.ts b/packages/ecs/src/interface/system/body.ts new file mode 100644 index 000000000..2bf63145d --- /dev/null +++ b/packages/ecs/src/interface/system/body.ts @@ -0,0 +1,49 @@ +import type { Component } from '../component/component.js'; +import type { Entity } from '../entity/entity.js'; +import type { Resource } from '../resource/resource.js'; +import type { Holding } from '../resource/condition.js'; +import type { Schema } from '../schema/schema.js'; +import type { Commands } from './commands.js'; +import type { Declaration } from './declaration.js'; +import type { Listed, Property } from './property.js'; +import type { RenderContext } from './render.js'; +import type { ComponentValues, LookupFunction, ResourceValues, Values } from './values.js'; + +/** `read` values are read-only, `write` values readable and writable. */ +export type Row = + { readonly entity: Entity } + & Values, "read">, Listed, "write">, Component.Key, ComponentValues>; + +type Resources = Listed, "read"> | Listed, "write"> | Is; +type Is = Listed, "is">; +type Reduced = keyof Property & Resource.Key; + +/** + * What a body uses besides its row, by kind, under the declaration's own keys: each is present only when the + * declaration declares something of its kind. `commands` is there for every system but a GPU kernel, a render system + * and an observer system. + */ +export type Context = + ([Resources] extends [never] ? {} : { + readonly resources: Values, "read">, Listed, "write">, Resource.Key, ResourceValues> + & { readonly [K in Is & Resource.Key]: Holding }; + }) + & ([keyof Property] extends [never] ? {} : { + readonly lookup: { readonly [K in keyof Property]: LookupFunction[K]> }; + }) + & ([Reduced] extends [never] ? {} : { + /** Each resource it reduces into, as a function contributing a value to the run's fold. */ + readonly reduce: { readonly [K in Reduced]: (value: Resource.Value) => void }; + }) + & (Property extends Schema ? { readonly input: Schema.ToType> } : {}) + & (Property extends object ? { + /** The parent's `up.read` values, or null for a row without a parent. */ + readonly up: { readonly [K in Listed, "read"> & Component.Key]: Component.Value } | null; + } : {}) + & (D extends { readonly kernel: "gpu" } | { readonly shader: object } | { readonly observer: true } ? {} : { readonly commands: Commands }); + +/** `(row, context)` for a system with `components`, `(context)` for one without, and `(gpu, context)` for a render system. */ +export type Body = + D extends { readonly shader: object } ? (gpu: RenderContext, context: Context) => void : + Property extends undefined ? (context: Context) => void : + (row: Row, context: Context) => void; diff --git a/packages/ecs/src/interface/system/check.ts b/packages/ecs/src/interface/system/check.ts new file mode 100644 index 000000000..3f1b9d57a --- /dev/null +++ b/packages/ecs/src/interface/system/check.ts @@ -0,0 +1,227 @@ +import { layoutOf } from '../registry/layout.js'; +import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { definitionOf } from '../registry/program.js'; +import { reducedBy, scheduleOf } from '../registry/schedule.js'; +import { conditional } from '../registry/schemas.js'; +import { requireRegistered, threadOf } from '../registry/threads.js'; +import { checkWorldWritten } from '../registry/written.js'; +import { checkLookup, indexDefinitionOf, lookupKeysOf, orderKeysOf } from '../registry/indexes.js'; +import { labelOfItem } from '../registry/items.js'; +import type { Schema } from '../schema/schema.js'; +import type { Composite } from './composite.js'; +import type { Definition } from './definition.js'; +import type { Leaf } from './leaf.js'; + +type Key = symbol; + +/** Throws if a key is in both `read` and `write`: `write` already makes it readable. */ +const checkLists = (name: string, what: string, lists: { readonly read?: readonly Key[]; readonly write?: readonly Key[] } | undefined): void => { + const writes = new Set(lists?.write); + const both = (lists?.read ?? []).find(key => writes.has(key)); + if (both !== undefined) throw new Error(`System "${name}" lists "${labelOf(both)}" in both ${what} read and write; write already makes it readable.`); +}; + +/** + * Throws if a leaf reduces into a resource with an operator its `reduce` trait doesn't declare, or also reads, writes + * or filters on it, or writes a resource that's reduced, which only its contributors' fold sets. + */ +const checkReductions = (leaf: Leaf): void => { + const { name } = leaf; + const { reduce, resources } = leaf.declaration; + const others = new Set([...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? []]); + for (const key of reducedBy(leaf)) { + const declared = declarationOf(key)?.reduce; + const operator: unknown = Reflect.get(reduce ?? {}, key); + if (declared === undefined) throw new Error(`System "${name}" reduces "${labelOf(key)}", which has no reduce trait.`); + if (declared !== operator) throw new Error(`System "${name}" reduces "${labelOf(key)}" with "${labelOf(operator)}", but it's declared reduce: "${labelOf(declared)}".`); + if (others.has(key)) throw new Error(`System "${name}" reduces "${labelOf(key)}", so it can't also read, write or filter on it.`); + } + for (const key of resources?.write ?? []) { + if (declarationOf(key)?.reduce !== undefined) throw new Error(`System "${name}" writes "${labelOf(key)}", which is reduced; only its contributors' fold sets it.`); + } +}; + +/** + * Throws if a condition isn't a resource whose value can be `null` or `false`, is in both `is` and `not`, or is also + * read or written: `is` already makes it readable. + */ +const checkConditions = (leaf: Leaf): void => { + const { name } = leaf; + const { resources } = leaf.declaration; + const is = resources?.is ?? []; + const not = resources?.not ?? []; + const listed = new Set([...resources?.read ?? [], ...resources?.write ?? []]); + for (const key of [...is, ...not]) { + const declaration = declarationOf(key); + if (kindOf(key) !== "resource" || declaration === undefined) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which isn't a registered resource.`); + const { type } = declaration; + if (type !== undefined && !conditional(type)) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which is never null or false.`); + } + for (const key of is) { + if (not.includes(key)) throw new Error(`System "${name}" lists "${labelOf(key)}" in both resources is and not.`); + if (listed.has(key)) throw new Error(`System "${name}" lists "${labelOf(key)}" in resources is and in read or write; is already makes it readable.`); + } +}; + +/** Why kernels can't read a component's values, or null when they can: they read only structs, in linear memory. */ +const unreadByKernels = (key: symbol): string | null => { + const { kind } = layoutOf(declarationOf(key)?.type); + return kind === "object" ? "which isn't stored in linear memory" : kind === "string" ? "a string, which kernels don't read yet" : null; +}; + +/** + * Throws for what a leaf's kind and placement rule out: `up` without components or through a key that isn't a reference + * component, `order` by anything but a sorted index, without components, with `up` or on the GPU, a kernel's lookup of + * a value outside linear memory, a GPU kernel's change filters, lookups and reductions, and a render system's + * reductions. + */ +const checkPlacement = (leaf: Leaf): void => { + const { name } = leaf; + const { kernel, components, lookup, up, resources, order } = leaf.declaration; + const fail = (message: string): never => { throw new Error(`System "${name}" ${message}.`); }; + if (order !== undefined) { + const index = indexDefinitionOf(order); + if (index === undefined) fail("orders its rows by a value Index.create didn't return"); + else if (index.declaration.sorted !== true) fail(`orders its rows by index "${labelOfItem(index)}", which isn't sorted`); + if (components === undefined) fail("orders its rows without components, so it runs once"); + if (up !== undefined) fail("uses both up and order, which both decide the order of its rows"); + if (kernel === "gpu") fail("can't run on the GPU: GPU kernels have no order yet"); + if (kernel !== undefined) { + for (const key of orderKeysOf(leaf.declaration)) { + const unread = unreadByKernels(key); + if (unread !== null) throw new Error(`Kernel "${name}": its rows are ordered by an index keyed by "${labelOf(key)}", ${unread}.`); + } + } + } + if (up !== undefined) { + if (components === undefined) fail("uses up, which orders rows, without components"); + if (kindOf(up.through) !== "component" || declarationOf(up.through)?.reference === undefined) { + throw new Error(`System "${name}": up.through "${labelOf(up.through)}" isn't a reference component.`); + } + } + const lookups = Object.values(lookup ?? {}); + if (kernel !== undefined) { + // Workers read lookups from shared memory, so their values must be linear there too. + for (const key of lookups.flatMap(found => found.read ?? [])) { + const unread = unreadByKernels(key); + if (unread !== null) throw new Error(`Kernel "${name}": a lookup reads "${labelOf(key)}", ${unread}.`); + } + for (const key of lookups.flatMap(lookupKeysOf)) { + const unread = unreadByKernels(key); + if (unread !== null) throw new Error(`Kernel "${name}": a lookup goes through an index keyed by "${labelOf(key)}", ${unread}.`); + } + } + const reduces = reducedBy(leaf).length > 0; + if (kernel === "gpu") { + if ((components?.added?.length ?? 0) + (components?.changed?.length ?? 0) + (resources?.changed?.length ?? 0) > 0) fail("can't run on the GPU: GPU kernels don't support change filters yet"); + if (lookups.length > 0) fail("can't run on the GPU: GPU kernels don't support lookups yet"); + if (reduces) fail("can't run on the GPU: GPU kernels don't support reductions yet"); + } + if (reduces && leaf.kind === "render") fail("can't reduce: a render system records GPU work rather than running rows"); + if ((leaf.declaration.shader !== undefined) !== (leaf.kind === "render")) fail("has a shader only when System.render makes it"); +}; + +/** + * Throws for what an observer system can't declare: it only reads, after a sync point, so it writes and reduces into + * nothing, takes no input, isn't a kernel or a render system, has no `up`, and is ordered with nothing. + */ +const checkObserver = (leaf: Leaf): void => { + const { name, declaration } = leaf; + const { observer } = declaration; + if (observer === undefined) return; + const fail = (message: string): never => { throw new Error(`System "${name}" is an observer, so it ${message}.`); }; + if (observer !== true) throw new Error(`System "${name}": observer is true or left out.`); + if (leaf.kind === "render") fail("records no GPU work"); + if (declaration.kernel !== undefined) fail("isn't a kernel"); + if ((declaration.components?.write?.length ?? 0) + (declaration.resources?.write?.length ?? 0) > 0) fail("writes nothing"); + if (reducedBy(leaf).length > 0) fail("reduces into nothing"); + if (declaration.input !== undefined) fail("takes no input"); + if (declaration.up !== undefined) fail("has no up"); + if ((declaration.before?.length ?? 0) + (declaration.after?.length ?? 0) + (declaration.during?.length ?? 0) > 0) fail("is ordered with nothing"); +}; + +/** + * Throws for a leaf that names a component or resource that isn't registered as one, lists a key in both `read` and + * `write`, writes a reference, has a lookup reading a component it writes, takes input that isn't an object schema, + * uses object components and resources of different threads, declares what an observer system can't, or whose + * reductions, conditions, `up` or placement can't hold. + */ +export const checkLeaf = (leaf: Leaf): void => { + const { name, declaration } = leaf; + const { components, resources, lookup, input } = declaration; + checkLists(name, "components", components); + checkLists(name, "resources", resources); + checkWorldWritten(`System "${name}"`, resources?.write); + const writes = new Set(components?.write); + for (const lookupName in lookup) { + checkLookup("System", name, lookupName, lookup[lookupName]); + for (const key of lookup[lookupName].read ?? []) { + if (writes.has(key)) throw new Error(`System "${name}": lookup "${lookupName}" reads "${labelOf(key)}", which the system writes.`); + } + for (const key of lookupKeysOf(lookup[lookupName])) { + if (writes.has(key)) throw new Error(`System "${name}": lookup "${lookupName}" goes through an index keyed by "${labelOf(key)}", which the system writes.`); + } + } + for (const key of orderKeysOf(declaration)) { + if (writes.has(key)) throw new Error(`System "${name}": its rows are ordered by an index keyed by "${labelOf(key)}", which the system writes.`); + } + if (input !== undefined) { + const schema: Schema = input; + if (!("type" in schema) || schema.type !== "object" || typeof schema.properties !== "object" || schema.properties === null) { + throw new Error(`System "${name}": input is an object schema of named fields.`); + } + } + checkTarget(leaf); + threadOf(declaration, "System", name); + requireRegistered("System", name, "component", components?.has); + requireRegistered("System", name, "component", components?.not); + for (const lookupName in lookup) { + const found = lookup[lookupName]; + requireRegistered("System", name, "component", "has" in found ? found.has : undefined); + requireRegistered("System", name, "component", "not" in found ? found.not : undefined); + } + for (const key of components?.write ?? []) { + if (declarationOf(key)?.reference !== undefined) throw new Error(`System "${name}" writes "${labelOf(key)}", a reference, which changes only through commands.`); + } + checkObserver(leaf); + checkReductions(leaf); + checkConditions(leaf); + checkPlacement(leaf); +}; + +/** + * Throws if a system on one entity takes it from what isn't an entity field of its input, has no components, or is a + * kernel, has `up` or `order`, or reduces. + */ +const checkTarget = (leaf: Leaf): void => { + const { name, declaration } = leaf; + const { target, input, components, kernel, up, order, reduce } = declaration; + if (target === undefined) return; + const fail = (message: string): never => { throw new Error(`System "${name}" runs on one entity, so it ${message}.`); }; + const field: Schema | undefined = input !== undefined && "properties" in input ? Reflect.get(input.properties, target) : undefined; + if (field === undefined || !("format" in field) || field.format !== "entity") fail(`takes it from an input field "${target}" holding an entity`); + if (components === undefined) fail("needs components"); + if (kernel !== undefined) fail("isn't a kernel"); + if (up !== undefined || order !== undefined) fail("has no up or order"); + if (reduce !== undefined) fail("reduces into nothing"); +}; + +/** Every leaf under a system, each once. Throws for a member no System constructor returned. */ +const leavesOf = (system: Definition, leaves: Set): Set => { + if (system.kind !== "composite") return leaves.add(system); + for (const member of system.declaration.systems) { + const definition = definitionOf(member); + if (definition === undefined) throw new Error(`System "${system.name}": member "${member.name}" isn't a system a System constructor returned.`); + leavesOf(definition, leaves); + } + return leaves; +}; + +/** + * Throws for a member no System constructor returned, members ordered in a cycle, two that conflict with no order + * between them, or an input field two members declare with different schemas. + */ +export const checkComposite = (composite: Composite): void => { + leavesOf(composite, new Set()); + scheduleOf(composite); +}; diff --git a/packages/ecs/src/interface/system/commands.ts b/packages/ecs/src/interface/system/commands.ts new file mode 100644 index 000000000..2a3242437 --- /dev/null +++ b/packages/ecs/src/interface/system/commands.ts @@ -0,0 +1,26 @@ +import type { Component } from '../component/component.js'; +import type { Entity } from '../entity/entity.js'; +import type { Essential } from '../component/value.js'; +import type { ValuesOf } from '../archetype/values-of.js'; + +/** The value an `add` of one component takes: none for a tag. */ +type ValueArgument = Component.Value extends true ? [value?: true] : [value: Component.Value]; + +/** + * Structural changes and writes to other entities, applied in issue order at the next sync point. They take only the + * components commands may add: every one but those rules derive. + */ +export type Commands = { + /** + * The spawner of entities of exactly `components`: the same function for a world and list each time, so calling + * it allocates nothing. It returns a real entity immediately, alive at the sync point. + */ + spawn(components: C): (values: ValuesOf) => Entity; + despawn(entity: Entity): void; + /** Adds or replaces one component's value, marking it changed. */ + add(entity: Entity, component: K, ...value: ValueArgument): void; + /** Adds or replaces the values of `components`, marking them changed. */ + add(entity: Entity, components: C, values: ValuesOf): void; + remove(entity: Entity, component: Essential): void; + remove(entity: Entity, components: readonly Essential[]): void; +}; diff --git a/packages/ecs/src/interface/system/commands.type-test.ts b/packages/ecs/src/interface/system/commands.type-test.ts new file mode 100644 index 000000000..77e13d01c --- /dev/null +++ b/packages/ecs/src/interface/system/commands.type-test.ts @@ -0,0 +1,93 @@ +import { Resource } from '../resource/resource.js'; +import { Component } from '../component/component.js'; +import { Archetype } from '../archetype/archetype.js'; +import type { Entity } from '../entity/entity.js'; +import type { Commands } from './commands.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const position = Symbol("commandsTypeTest/position"); +const velocity = Symbol("commandsTypeTest/velocity"); +const label = Symbol("commandsTypeTest/label"); +const player = Symbol("commandsTypeTest/player"); +const slot = Symbol("commandsTypeTest/slot"); +const time = Symbol("commandsTypeTest/time"); +declare module '@adobe/ecs' { + export interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + [label]: { type: string | null }; + [player]: { type: true }; + [slot]: { type: number; derived: true }; + } + export interface Resources { + [time]: { type: number }; + } +} +Component.register(position, { type: { type: "number" } }); +Component.register(velocity, { type: { type: "number" } }); +Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); +Component.register(player, { type: { const: true } }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Resource.register(time, { type: { type: "number", default: 0 } }); + +const particle = Archetype.create([position, velocity, label, player]); + +export const issue = (commands: Commands, entity: Entity): void => { + // `spawn` is curried: the components first, then their values, giving an entity. + const insert = commands.spawn(particle); + const spawned: Entity = insert({ [position]: 0, [velocity]: 1 }); + check[0], { readonly [position]: number; readonly [velocity]: number } & { readonly [label]?: string | null; readonly [player]?: true }>>(); + // A tag needs no value, nor does a component whose type allows null; any other is required. + insert({ [position]: 0, [velocity]: 1, [label]: "a", [player]: true }); + // @ts-expect-error velocity has no default + insert({ [position]: 0 }); + // @ts-expect-error values are exact against the components: velocity isn't in this spawn + commands.spawn([position])({ [position]: 0, [velocity]: 1 }); + // @ts-expect-error values have the components' types + commands.spawn([position])({ [position]: "zero" }); + // A plain or inline list spawns too. + commands.spawn([position, player])({ [position]: 0 }); + commands.spawn([])({}); + // A list that isn't a tuple takes any of its components' values. + const dynamic: (typeof position | typeof player)[] = [position]; + commands.spawn(dynamic)({}); + commands.spawn(dynamic)({ [position]: 1 }); + // @ts-expect-error but only theirs + commands.spawn(dynamic)({ [velocity]: 1 }); + // @ts-expect-error spawn takes its components first + commands.spawn({ [position]: 0 }); + // @ts-expect-error commands never add a derived component + commands.spawn([slot]); + // @ts-expect-error nor a resource + commands.spawn([time]); + void spawned; + + // `add` takes one component and its value, or a list and its values; a tag needs no value. + commands.add(entity, position, 1); + commands.add(entity, player); + commands.add(entity, player, true); + // @ts-expect-error a component with a value type takes its value + commands.add(entity, position); + // @ts-expect-error of its type + commands.add(entity, position, "one"); + commands.add(entity, [position, player], { [position]: 1 }); + commands.add(entity, particle, { [position]: 1, [velocity]: 2 }); + // @ts-expect-error a list's values are exact against it + commands.add(entity, [position], { [position]: 1, [velocity]: 2 }); + // @ts-expect-error the list form requires its components + commands.add(entity, { [position]: 1 }); + // @ts-expect-error commands never add a derived component + commands.add(entity, slot, 1); + + // `remove` takes one component or one list. + commands.remove(entity, position); + commands.remove(entity, [position, player]); + commands.remove(entity, particle); + // @ts-expect-error no rest parameters + commands.remove(entity, position, velocity); + // @ts-expect-error commands never remove a derived component + commands.remove(entity, slot); + commands.despawn(entity); +}; diff --git a/packages/ecs/src/interface/system/composite-declaration.ts b/packages/ecs/src/interface/system/composite-declaration.ts new file mode 100644 index 000000000..141f0c52e --- /dev/null +++ b/packages/ecs/src/interface/system/composite-declaration.ts @@ -0,0 +1,14 @@ +import type { Name } from './name.js'; +import type { System } from './system.js'; + +/** Each run passes every member the same input, with the fields of all their inputs. */ +export type CompositeDeclaration = { + readonly name?: Name; + readonly systems: readonly System[]; + /** Hard constraint, applied to every member. */ + readonly before?: readonly System[]; + /** Hard constraint, applied to every member. */ + readonly after?: readonly System[]; + /** Runs inside the span of the systems listed: after what they follow and before what follows them. Applied to every member. */ + readonly during?: readonly System[]; +}; diff --git a/packages/ecs/src/interface/system/composite-input.type-test.ts b/packages/ecs/src/interface/system/composite-input.type-test.ts new file mode 100644 index 000000000..456b92774 --- /dev/null +++ b/packages/ecs/src/interface/system/composite-input.type-test.ts @@ -0,0 +1,63 @@ +import type { World } from '../world/world.js'; +import { System } from './system.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const move = System.create({ + name: "compositeInputTest/move", + input: { type: "object", properties: { distance: { type: "number" } } }, +}, ({ input }) => { void input.distance; }); +const step = System.create({ + name: "compositeInputTest/step", + input: { type: "object", properties: { distance: { type: "number" }, dt: { type: "number" } } }, +}, ({ input }) => { void input.dt; }); +const pause = System.create({ + name: "compositeInputTest/pause", + input: { type: "object", properties: { dt: { type: "number" } }, required: [] }, +}, ({ input }) => { void input.dt; }); +const label = System.create({ + name: "compositeInputTest/label", + input: { type: "object", properties: { distance: { type: "string" } } }, +}, ({ input }) => { void input.distance; }); +const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); + +// A composite takes every field its members' inputs have; a field in several is one value for all of them. +const frame = System.create({ name: "compositeInputTest/frame", systems: [move, step, idle] }); +check>>(); + +// A field optional in one member and required in another is required. +const paused = System.create({ name: "compositeInputTest/paused", systems: [pause, step] }); +check>>(); + +// Nested composites take their members' fields too. +const outer = System.create({ name: "compositeInputTest/outer", systems: [System.create({ name: "compositeInputTest/inner", systems: [move] }), pause] }); +check>>(); + +// A composite without a name or constraints is its list of members. +const listed = System.create([move, step, idle]); +check>>(); +const constrained = System.create({ systems: [pause], after: [listed] }); +check>>(); + +// Without inputs, a composite takes none. +const quiet = System.create({ name: "compositeInputTest/quiet", systems: [idle] }); +check>(); + +// @ts-expect-error a field with different types in two members is an error at the member +System.create({ name: "compositeInputTest/conflict", systems: [move, label] }); +// @ts-expect-error in a list of members too +System.create([move, label]); +// @ts-expect-error a composite declaration lists its systems +System.create({ name: "compositeInputTest/empty", after: [move] }); + +declare const world: World; +void world.run(frame, { distance: 1, dt: 0.5 }); +// @ts-expect-error run takes every field +void world.run(frame, { distance: 1 }); + +System.create({ + name: "compositeInputTest/scalar", + // @ts-expect-error input is an object of named fields + input: { type: "number" }, +}, () => {}); diff --git a/packages/ecs/src/interface/system/composite.ts b/packages/ecs/src/interface/system/composite.ts new file mode 100644 index 000000000..d7cd114e7 --- /dev/null +++ b/packages/ecs/src/interface/system/composite.ts @@ -0,0 +1,5 @@ +import type { CompositeDeclaration } from './composite-declaration.js'; +import type { Name } from './name.js'; + +/** A system made of subsystems; members are flattened and ordered by their constraints. */ +export type Composite = { readonly kind: "composite"; readonly name: Name; readonly declaration: CompositeDeclaration }; diff --git a/packages/ecs/src/interface/system/conditions.type-test.ts b/packages/ecs/src/interface/system/conditions.type-test.ts new file mode 100644 index 000000000..8bd314857 --- /dev/null +++ b/packages/ecs/src/interface/system/conditions.type-test.ts @@ -0,0 +1,68 @@ +import { Resource } from '../resource/resource.js'; +import { Component } from '../component/component.js'; +import { System } from './system.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const paused = Symbol("conditionsTypeTest/paused"); +const level = Symbol("conditionsTypeTest/level"); +const cache = Symbol("conditionsTypeTest/cache"); +const count = Symbol("conditionsTypeTest/count"); +const handle = Symbol("conditionsTypeTest/handle"); +const position = Symbol("conditionsTypeTest/position"); + +declare module '@adobe/ecs' { + export interface Resources { + [paused]: { type: boolean }; + [level]: { type: { readonly size: number } | null }; + [cache]: { type: Map | null }; + [count]: { type: number }; + [handle]: { type: Map }; + } + export interface Components { + [position]: { type: number }; + } +} +Resource.register(handle, { default: new Map() }); +Resource.register(level, { type: { anyOf: [{ type: "object", properties: { size: { type: "number" } } }, { type: "null" }], default: null } }); +Resource.register(paused, { type: { type: "boolean", default: false } }); +Resource.register(cache, {}); +Resource.register(count, { type: { type: "number", default: 0 } }); +Component.register(position, { type: { type: "number" } }); + +// `is`: runs while each is neither null nor false, and reads it narrowed so. +System.create({ name: "conditionsTypeTest/loaded", resources: { is: [level, cache] } }, ({ resources }) => { + check>(); + check>>(); + // @ts-expect-error a condition is read-only + resources[level] = null; +}); +System.create({ name: "conditionsTypeTest/playing", resources: { is: [paused] } }, ({ resources }) => { + check>(); +}); + +// `not`: runs while each is null or false; its value is known, so it isn't readable. +System.create({ name: "conditionsTypeTest/running", components: { write: [position] }, resources: { not: [paused] } }, (row, context) => { + row[position] += 1; + // @ts-expect-error a `not` condition isn't readable + void context.resources; +}); + +// Only a resource whose type allows null or false is a condition. +// @ts-expect-error a number is never null or false +System.create({ name: "conditionsTypeTest/counted", resources: { is: [count] } }, () => {}); +// @ts-expect-error a value that isn't data and doesn't allow null is never null +System.create({ name: "conditionsTypeTest/handled", resources: { not: [handle] } }, () => {}); + +// A condition is listed once: `is` already makes it readable, and a written value would have two types. +// @ts-expect-error both is and not +System.create({ name: "conditionsTypeTest/both", resources: { is: [paused], not: [paused] } }, () => {}); +// @ts-expect-error is already makes it readable +System.create({ name: "conditionsTypeTest/read", resources: { is: [level], read: [level] } }, () => {}); +// @ts-expect-error a condition isn't written +System.create({ name: "conditionsTypeTest/write", resources: { is: [level], write: [level] } }, () => {}); + +// Kernels and render systems take conditions too. +System.create({ name: "conditionsTypeTest/kernel", kernel: "cpu", components: { write: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); +System.render({ name: "conditionsTypeTest/draw", resources: { is: [level] }, shader: { accessors: { place: position } } }, () => {}); diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts new file mode 100644 index 000000000..b6078fdd6 --- /dev/null +++ b/packages/ecs/src/interface/system/create.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import { Component } from "../component/component.js"; +import { Entity } from "../entity/entity.js"; +import { definitionOf, firstItemOf, labelOfItem, requireDefinition, sameItem, scheduleOf, threadOf } from "../registry/index.js"; +import { identify } from "../registry/ids.test-helper.js"; +import { Resource } from "../resource/resource.js"; +import { clock, gpuDevice } from "../resources/index.js"; +import { System } from "./system.js"; + +const position = Symbol("typesSystemTest/position"); +const label = Symbol("typesSystemTest/label"); +const node = Symbol("typesSystemTest/node"); +const document = Symbol("typesSystemTest/document"); +const time = Symbol("typesSystemTest/time"); +const unregistered = Symbol("typesSystemTest/unregistered"); +const parent = Symbol("typesSystemTest/parent"); +const total = Symbol("typesSystemTest/total"); +const paused = Symbol("typesSystemTest/paused"); +const height = Symbol("typesSystemTest/height"); +const tags = Symbol("typesSystemTest/tags"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: number }; + [label]: { type: string }; + [node]: { type: HTMLElement; thread: "main" }; + [unregistered]: { type: number }; + [parent]: { type: Entity; reference: "weak" }; + [height]: { type: number }; + [tags]: { type: readonly string[] }; + } + export interface Resources { + [document]: { type: Document | null; thread: "main" }; + [time]: { type: number }; + [total]: { type: number; reduce: "+" }; + [paused]: { type: boolean }; + } +} + +Component.register(position, { type: { type: "number" } }); +Component.register(label, { type: { type: "string" } }); +Component.register(node, { thread: "main" }); +Component.register(tags, { type: { type: "array", items: { type: "string" } } }); +Resource.register(document, { thread: "main" }); +Resource.register(time, { type: { type: "number", default: 0 } }); +Component.register(parent, { type: Entity.schema, reference: "weak" }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(paused, { type: { type: "boolean", default: false } }); +Component.register(height, { type: { type: "number", precision: 1 } }); + +/** Creates as an untyped caller would, bypassing the types' checks. */ +const create = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + +describe("System.create", () => { + it("records the system, its value holding its declaration and body", () => { + const body = (): void => {}; + const move = System.create({ name: "typesSystemTest/move", components: { write: [position] }, resources: { read: [time] } }, body); + expect(definitionOf(move)).toEqual({ kind: "system", name: "typesSystemTest/move", declaration: { name: "typesSystemTest/move", components: { write: [position] }, resources: { read: [time] } }, body }); + expect(labelOfItem(move)).toBe("typesSystemTest/move"); + expect(definitionOf({ name: "typesSystemTest/move" })).toBe(undefined); + }); + it("keeps systems of one name apart, labeling each by its name numbered", () => { + const first = System.create({ name: "typesSystemTest/twice", components: { read: [position] } }, row => { void row[position]; }); + expect(labelOfItem(first)).toBe("typesSystemTest/twice"); + const again = System.create({ name: "typesSystemTest/twice", components: { read: [position] } }, row => { void row[position]; }); + const other = System.create({ name: "typesSystemTest/twice", components: { write: [position] } }, row => { row[position] = 0; }); + expect(sameItem(first, again)).toBe(false); + expect([first, again, other].map(labelOfItem)).toEqual(["typesSystemTest/twice", "typesSystemTest/twice#2", "typesSystemTest/twice#3"]); + expect(scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/twice", systems: [first, again] }))).tiers.flat()).toEqual([first, again]); + }); + it("labels systems of one name by name and id, and takes a declaration of a registered id, the same, as the same system", () => { + const declare = (id: string, write: boolean) => System.create(identify({ name: "typesSystemTest/linked", components: write ? { write: [position] } : { read: [position] } }, id), () => {}); + const first = declare("a.ts#linked", false); + const elsewhere = declare("b.ts#linked", false); + expect([labelOfItem(first), labelOfItem(elsewhere)]).toEqual(["typesSystemTest/linked (a.ts#linked)", "typesSystemTest/linked (b.ts#linked)"]); + const again = declare("a.ts#linked", false); + expect(again).not.toBe(first); + expect([sameItem(first, again), sameItem(first, elsewhere), firstItemOf(again)]).toEqual([true, false, first]); + expect(labelOfItem(again)).toBe("typesSystemTest/linked (a.ts#linked)"); + const write = System.create({ name: "typesSystemTest/afterLinked", components: { write: [position] }, after: [again] }, row => { row[position] = 0; }); + const tiers = scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/linkedFrame", systems: [write, first, again] }))).tiers; + expect(tiers).toEqual([[first], [write]]); + expect(() => declare("a.ts#linked", true)).toThrow('Cannot declare "typesSystemTest/linked" (a.ts#linked): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); + expect(() => System.create(identify({ name: "typesSystemTest/linked", systems: [first] }, "a.ts#linked"))).toThrow("its id is a system's"); + expect(labelOfItem(declare("c.ts#linked", false))).toBe("typesSystemTest/linked (c.ts#linked)"); + }); + it("names a system declared without a name by its binding and id, or else by its kind numbered", () => { + const body = ({ commands }: { readonly commands: System.Commands }): void => { commands.spawn([position])({ [position]: 0 }); }; + const alone = System.create(identify(body, "setup.ts#spawnOnce")); + expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce (setup.ts#spawnOnce)", declaration: {}, body }); + expect(labelOfItem(alone)).toBe("spawnOnce (setup.ts#spawnOnce)"); + expect(sameItem(System.create(identify(body, "setup.ts#spawnOnce")), alone)).toBe(true); + const show = System.create(identify({ components: { read: [position] } }, "show.ts#show"), row => { void row[position]; }); + expect(labelOfItem(show)).toBe("show (show.ts#show)"); + const [first, second] = [System.create(() => {}), System.create({ systems: [show] })].map(labelOfItem); + const number = Number(first?.slice("system #".length)); + expect([first, second]).toEqual([`system #${number}`, `system #${number + 1}`]); + expect(labelOfItem(System.render({ shader: { accessors: {} } }, () => {}))).toBe(`system #${number + 2}`); + expect(() => create({ components: { read: [position], write: [position] } })).toThrow(`System "system #${number + 3}" lists "typesSystemTest/position" in both components read and write`); + }); + it("rejects writing what only the world writes, for untyped callers", () => { + expect(() => create({ name: "typesSystemTest/clock", resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); + }); + it("rejects what types reject, for untyped callers", () => { + expect(() => create({ name: "typesSystemTest/both", components: { read: [position], write: [position] } })).toThrow("in both components read and write"); + expect(() => create({ name: "typesSystemTest/bothResources", resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); + expect(() => create({ name: "typesSystemTest/lookup", components: { write: [position] }, lookup: { others: { read: [position] } } })).toThrow('lookup "others" reads "typesSystemTest/position", which the system writes'); + expect(() => create({ name: "typesSystemTest/input", input: { type: "number" } })).toThrow("input is an object schema of named fields"); + expect(labelOfItem(System.create({ name: "typesSystemTest/both" }, () => {}))).toBe("typesSystemTest/both"); + }); + it("rejects at creation what the registry rules out, for untyped callers", () => { + const rejected: readonly (readonly [object, string])[] = [ + [{ components: { read: [position], has: [unregistered] } }, `uses component "typesSystemTest/unregistered", which isn't a registered component`], + [{ components: { read: [position] }, lookup: { others: { read: [position], not: [unregistered] } } }, `uses component "typesSystemTest/unregistered", which isn't a registered component`], + [{ components: { read: [position] }, lookup: { others: { read: [position], changed: [height] } } }, `lookup "others" watches "typesSystemTest/height", which it neither reads nor has`], + [{ components: { write: [parent] } }, `writes "typesSystemTest/parent", a reference, which changes only through commands`], + [{ components: { has: [position] }, reduce: { [total]: "max" } }, `reduces "typesSystemTest/total" with "max", but it's declared reduce: "+"`], + [{ components: { has: [position] }, reduce: { [time]: "+" } }, `reduces "typesSystemTest/time", which has no reduce trait`], + [{ resources: { write: [total] } }, `writes "typesSystemTest/total", which is reduced`], + [{ components: { has: [position] }, resources: { read: [total] }, reduce: { [total]: "+" } }, `reduces "typesSystemTest/total", so it can't also read, write or filter on it`], + [{ resources: { is: [time] } }, `runs on the condition of "typesSystemTest/time", which is never null or false`], + [{ resources: { not: [position] } }, `runs on the condition of "typesSystemTest/position", which isn't a registered resource`], + [{ resources: { is: [paused], not: [paused] } }, `lists "typesSystemTest/paused" in both resources is and not`], + [{ resources: { is: [paused], read: [paused] } }, `lists "typesSystemTest/paused" in resources is and in read or write; is already makes it readable`], + [{ components: { write: [position] }, up: { through: position, read: [position] } }, `up.through "typesSystemTest/position" isn't a reference component`], + [{ up: { through: parent } }, "uses up, which orders rows, without components"], + [{ kernel: "gpu", components: { write: [position], changed: [position] } }, "can't run on the GPU: GPU kernels don't support change filters yet"], + [{ kernel: "gpu", components: { read: [position] }, lookup: { others: { read: [position] } } }, "can't run on the GPU: GPU kernels don't support lookups yet"], + [{ kernel: "gpu", components: { has: [position] }, reduce: { [total]: "+" } }, "can't run on the GPU: GPU kernels don't support reductions yet"], + [{ observer: false, resources: { read: [time] } }, "observer is true or left out"], + [{ observer: true, components: { write: [position] } }, "is an observer, so it writes nothing"], + [{ observer: true, resources: { write: [time] } }, "is an observer, so it writes nothing"], + [{ observer: true, components: { has: [position] }, reduce: { [total]: "+" } }, "is an observer, so it reduces into nothing"], + [{ observer: true, input: { type: "object", properties: {} } }, "is an observer, so it takes no input"], + [{ observer: true, kernel: "cpu", components: { read: [position] } }, "is an observer, so it isn't a kernel"], + [{ observer: true, components: { read: [position] }, up: { through: parent } }, "is an observer, so it has no up"], + [{ observer: true, resources: { read: [time] }, after: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], + [{ observer: true, resources: { read: [time] }, before: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], + [{ observer: true, resources: { read: [time] }, during: [System.create(() => {})] }, "is an observer, so it is ordered with nothing"], + ]; + rejected.forEach(([declaration, message], index) => { + expect(() => create({ name: `typesSystemTest/rejected/${index}`, ...declaration })).toThrow(message); + // A rejected system records nothing, so its name labels only the next. + expect(labelOfItem(System.create({ name: `typesSystemTest/rejected/${index}` }, () => {}))).toBe(`typesSystemTest/rejected/${index}`); + }); + }); + it("derives its thread from its object components and resources, and rejects two", () => { + const show = System.create({ name: "typesSystemTest/show", components: { read: [position, node] }, resources: { read: [time, document] } }, (row, { resources }) => { row[node].textContent = `${row[position]} ${resources[time]} ${resources[document]?.title}`; }); + const count = System.create({ name: "typesSystemTest/count", components: { read: [tags], has: [node] } }, row => { void row[tags]; }); + const tick = System.create({ name: "typesSystemTest/tick", components: { write: [position] } }, row => { row[position] += 1; }); + const leaves = [show, count, tick].map(system => definitionOf(system)); + expect(leaves.map(leaf => leaf === undefined || leaf.kind === "composite" ? undefined : threadOf(leaf.declaration, "System", leaf.name))).toEqual(["main", "world", null]); + expect(() => create({ name: "typesSystemTest/span", components: { read: [tags], write: [node] } })).toThrow('System "typesSystemTest/span" uses "typesSystemTest/tags" on "world" and "typesSystemTest/node" on "main"'); + expect(() => create({ name: "typesSystemTest/spanResource", components: { read: [tags] }, resources: { is: [document] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/document" on "main"'); + // A string lives in shared memory, so it goes with any thread's objects. + const caption = System.create({ name: "typesSystemTest/caption", components: { read: [label, node] } }, row => { row[node].textContent = row[label]; }); + const captionLeaf = definitionOf(caption); + expect(captionLeaf === undefined || captionLeaf.kind === "composite" ? undefined : threadOf(captionLeaf.declaration, "System", captionLeaf.name)).toBe("main"); + expect(() => create({ name: "typesSystemTest/unregistered", components: { read: [unregistered] } })).toThrow(`uses component "typesSystemTest/unregistered", which isn't a registered component`); + expect(() => create({ name: "typesSystemTest/wrongKind", components: { read: [time] } })).toThrow(`uses component "typesSystemTest/time", which isn't a registered component`); + }); +}); + +describe("System.create of a composite", () => { + it("records a composite whose members may live on different threads, from its list or its declaration", () => { + const show = System.create({ name: "typesSystemTest/showMember", components: { read: [node] } }, row => { row[node].hidden = true; }); + const name = System.create({ name: "typesSystemTest/nameMember", components: { write: [label] } }, row => { row[label] = ""; }); + const frame = System.create({ name: "typesSystemTest/frame", systems: [show, name] }); + expect(definitionOf(frame)?.kind).toBe("composite"); + expect(definitionOf(System.create({ name: "typesSystemTest/frame", systems: [show] }))).not.toBe(definitionOf(frame)); + const listed = System.create([show, name]); + const listedDefinition = definitionOf(listed); + expect(listedDefinition?.kind === "composite" ? listedDefinition.declaration.systems : undefined).toEqual([show, name]); + expect(listed.name).toMatch(/^system #\d+$/); + }); + it("tells a composite's declaration from a system's by its body", () => { + const untyped: (...parameters: never[]) => unknown = System.create; + expect(() => Reflect.apply(untyped, undefined, [{ name: "typesSystemTest/bodiless" }])).toThrow("A system needs a body, and a composite its systems."); + const leaf = Reflect.apply(untyped, undefined, [{ name: "typesSystemTest/leafWithSystems", systems: [] }, () => {}]); + expect(definitionOf(leaf)?.kind).toBe("system"); + }); + it("rejects members ordered in a cycle, and conflicting members with no order between them", () => { + const first = System.create({ name: "typesSystemTest/first", components: { write: [position] } }, row => { row[position] += 1; }); + const second = System.create({ name: "typesSystemTest/second", components: { read: [position] }, after: [first] }, row => { void row[position]; }); + const third = System.create({ name: "typesSystemTest/third", components: { read: [position] } }, row => { void row[position]; }); + expect(scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/ordered", systems: [second, first] }))).tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["typesSystemTest/first"], ["typesSystemTest/second"]]); + expect(() => System.create({ name: "typesSystemTest/unordered", systems: [first, third] })).toThrow('Systems "typesSystemTest/first" and "typesSystemTest/third" both access "typesSystemTest/position" and one writes it'); + const looped = System.create({ name: "typesSystemTest/looped", before: [first], after: [second] }, () => {}); + expect(() => System.create({ name: "typesSystemTest/cycle", systems: [first, second, looped] })).toThrow("Ordering cycle"); + expect(labelOfItem(System.create({ name: "typesSystemTest/unordered", systems: [first] }))).toBe("typesSystemTest/unordered"); + }); + it("runs observer members after its sync point, in no tier, never conflicting and ignoring constraints naming them", () => { + const write = System.create({ name: "typesSystemTest/observerWriter", components: { write: [position] } }, row => { row[position] += 1; }); + const watch = System.create({ name: "typesSystemTest/observer", observer: true, components: { read: [position] } }, row => { void row[position]; }); + const later = System.create({ name: "typesSystemTest/afterObserver", components: { read: [position] }, after: [watch, write] }, row => { void row[position]; }); + const free = System.create({ name: "typesSystemTest/afterObserverOnly", resources: { read: [time] }, after: [watch] }, ({ resources }) => { void resources[time]; }); + const schedule = scheduleOf(requireDefinition(System.create([write, watch, later, free]))); + expect(schedule.tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["typesSystemTest/observerWriter", "typesSystemTest/afterObserverOnly"], ["typesSystemTest/afterObserver"]]); + expect(schedule.observer.map(leaf => leaf.name)).toEqual(["typesSystemTest/observer"]); + const alone = scheduleOf(requireDefinition(watch)); + expect([alone.tiers.length, alone.observer.map(leaf => leaf.name)]).toEqual([0, ["typesSystemTest/observer"]]); + }); + it("rejects a member no constructor returned, and an input field two members declare differently", () => { + expect(() => System.create({ name: "typesSystemTest/forged", systems: [{ name: "typesSystemTest/showMember" }] })).toThrow('member "typesSystemTest/showMember" isn\'t a system a System constructor returned'); + const move = System.create({ name: "typesSystemTest/moveBy", input: { type: "object", properties: { distance: { type: "number" } } } }, () => {}); + const tag = System.create({ name: "typesSystemTest/tagBy", input: { type: "object", properties: { distance: { type: "string" } } } }, () => {}); + const nested = System.create({ name: "typesSystemTest/nested", systems: [tag] }); + const untyped: (...parameters: never[]) => unknown = System.create; + const composite = (declaration: object): unknown => Reflect.apply(untyped, undefined, [declaration]); + expect(() => composite({ name: "typesSystemTest/conflict", systems: [move, nested] })).toThrow('Systems "typesSystemTest/moveBy" and "typesSystemTest/tagBy" declare input field "distance" with different schemas.'); + expect(labelOfItem(System.create({ name: "typesSystemTest/conflict", systems: [move] }))).toBe("typesSystemTest/conflict"); + }); +}); + +describe("System.render", () => { + it("reads the components its accessors name, once each, and runs only while gpuDevice is set", () => { + const draw = System.render({ name: "typesSystemTest/draw", shader: { accessors: { place: height, again: height } } }, () => {}); + const definition = definitionOf(draw); + expect(definition?.kind === "render" ? [definition.declaration.components, definition.declaration.resources] : null).toEqual([{ read: [height] }, { is: [gpuDevice] }]); + const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); + expect(() => render({ name: "typesSystemTest/drawDocument", resources: { read: [document] }, shader: { accessors: {} } })).toThrow('uses "typesSystemTest/document" on "main" and "gpuDevice" on "world"'); + }); + it("rejects at creation accessors its shader can't read, and reductions", () => { + const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); + expect(() => System.render({ name: "typesSystemTest/drawReserved", shader: { accessors: { loop: height } } }, () => {})).toThrow(`System "typesSystemTest/drawReserved" can't render: "loop" isn't usable as a WGSL function name.`); + expect(() => render({ name: "typesSystemTest/drawReduced", shader: { accessors: {} }, reduce: { [total]: "+" } })).toThrow("can't reduce: a render system records GPU work rather than running rows"); + expect(() => render({ name: "typesSystemTest/drawObserver", shader: { accessors: {} }, observer: true })).toThrow("is an observer, so it records no GPU work"); + expect(labelOfItem(System.render({ name: "typesSystemTest/drawReserved", shader: { accessors: {} } }, () => {}))).toBe("typesSystemTest/drawReserved"); + }); +}); diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts new file mode 100644 index 000000000..81fe82db3 --- /dev/null +++ b/packages/ecs/src/interface/system/create.ts @@ -0,0 +1,181 @@ +import type { Component } from '../component/component.js'; +import type { Entity } from '../entity/entity.js'; +import type { Index } from '../indexes/index-type.js'; +import { takeIdOfDeclaration } from '../registry/ids.js'; +import { nameOf } from '../registry/items.js'; +import { claimSystem } from '../registry/program.js'; +import type { Resource } from '../resource/resource.js'; +import type { Schema } from '../schema/schema.js'; +import type { Body } from './body.js'; +import type { Commands } from './commands.js'; +import { checkComposite, checkLeaf } from './check.js'; +import type { Composite } from './composite.js'; +import type { CompositeDeclaration } from './composite-declaration.js'; +import type { Declaration } from './declaration.js'; +import type { CheckMembers, CompositeInputOf, InputOf } from './input.js'; +import type { Leaf } from './leaf.js'; +import type { Listed, Property } from './property.js'; +import type { StructComponent, StructResource } from './struct.js'; +import type { System } from './system.js'; +import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; +import type { HeldBy } from './values.js'; + +type Reduced = keyof Property & Resource.Key; +type Written = Listed, "write">; +type Is = Listed, "is">; +type Lookups = Property; + +/** + * An index lookup reads and watches only what every entity its index holds has, and its system doesn't write what it + * finds rows by or reads through it. A lookup by entity watches only what it reads or has. + */ +type IndexLookups = { + readonly lookup?: { + readonly [N in keyof Lookups]?: Lookups[N] extends { readonly index: Index } + ? { + readonly index?: Index<{ readonly key: readonly Exclude>[] }>; + readonly read?: readonly Exclude, Written>[]; + readonly changed?: readonly HeldBy[] | true; + } + : { readonly changed?: readonly (Listed[N], "read"> | Listed[N], "has">)[] } + }; +}; + +/** A system's rows are ordered by an index whose key parts it doesn't write. */ +type Order = { readonly order?: Index<{ readonly key: readonly Exclude>[] }> }; + +/** + * A key in `write` is readable too, so it isn't also in `read`. A system doesn't read or filter on what it reduces + * into, since the fold is only finished after every contributor runs. A condition in `is` is readable too, and isn't + * written or also in `not`. + */ +type Lists = { + readonly components?: { readonly read?: readonly Exclude>[] }; + readonly resources?: { + readonly read?: readonly Exclude | Reduced | Is>[]; + readonly write?: readonly Exclude>[]; + readonly changed?: readonly Exclude>[]; + readonly not?: readonly Exclude>[]; + }; +}; + +type Kernel = { + readonly components?: { readonly read?: readonly StructComponent[]; readonly write?: readonly StructComponent[] }; + readonly lookup?: { + readonly [name: string]: + | { readonly read?: readonly StructComponent[]; readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly changed?: readonly Component.Key[]; readonly index?: never } + | { readonly read?: readonly StructComponent[]; readonly changed?: readonly Component.Key[] | true; readonly index: Index<{ readonly key: readonly StructComponent[] }> }; + }; + readonly up?: { readonly read?: readonly StructComponent[] }; + readonly order?: Index<{ readonly key: readonly StructComponent[] }>; + readonly resources?: { readonly read?: readonly StructResource[]; readonly write?: never }; +}; + +/** What a GPU kernel can't use yet, beyond any kernel's limits. */ +type GpuKernel = { + readonly lookup?: { readonly [name: string]: never }; + readonly components?: { readonly added?: never; readonly changed?: never }; + readonly resources?: { readonly changed?: never }; + readonly reduce?: { readonly [K in Resource.Key]?: never }; + readonly order?: { readonly "error: GPU kernels have no order yet": true }; +}; + +/** + * An observer system only reads, after a sync point: it writes and reduces into nothing, takes no input, isn't a + * kernel, has no `up`, and is ordered with nothing. + */ +type Observer = { + readonly kernel?: never; + readonly components?: { readonly write?: never }; + readonly resources?: { readonly write?: never }; + readonly reduce?: never; + readonly input?: never; + readonly up?: never; + readonly before?: never; + readonly after?: never; + readonly during?: never; +}; + +/** The fields of input `I` that hold an entity. */ +type EntityFields = { [K in keyof I]-?: [Exclude] extends [Entity] ? K : never }[keyof I] & string; + +/** A system on one entity takes it from an entity field of its input, and runs on its row, in no order. */ +type Targeted = { + readonly target?: EntityFields>; + readonly kernel?: never; + readonly up?: never; + readonly order?: never; + readonly reduce?: never; +}; + +type Validate = + Lists + & IndexLookups + & Order + & (D extends { readonly input: Schema } ? { readonly components?: { readonly added?: never; readonly changed?: never } } : {}) + & (D extends { readonly kernel: "cpu" | "gpu" } ? Kernel : {}) + & (D extends { readonly kernel: "gpu" } ? GpuKernel : {}) + // `up` and `order` order rows, so a system without them has neither, and one has at most one of them. + & (Property extends undefined ? { readonly up?: { readonly [key: string]: never }; readonly order?: { readonly "error: a system without components runs once, in no order": true } } : {}) + & (D extends { readonly up: object } ? { readonly order?: { readonly "error: up and order both decide the order of its rows": true } } : {}) + & (D extends { readonly observer: true } ? Observer : {}) + & (D extends { readonly target: string } ? Targeted : {}) + & (D extends { readonly target: string } ? Property extends undefined ? { readonly target?: { readonly "error: a system on one entity runs on its row, so it needs components": true } } : {} : {}) + & { readonly shader?: never }; + +/** The body of a system that declares nothing, which runs once per run with its commands. */ +type Alone = (context: { readonly commands: Commands }) => void; + +/** A composite: its list of members, or its declaration. */ +type Composition = readonly System[] | CompositeDeclaration; + +/** A composite's members. */ +type MembersOf = C extends CompositeDeclaration ? C["systems"] : C; + +/** A member whose input has a field of a different type than another member's becomes the error saying so. */ +type Checked = C extends Alone ? unknown : C extends CompositeDeclaration ? { readonly systems: CheckMembers } : CheckMembers; + +/** + * Declares a system and returns it; nothing runs. The arguments tell the forms apart: + * - `create(body)`: a system that declares nothing, whose body runs once per run with its commands. + * - `create(declaration, body)`: a system. + * - `create([move, collide])`: a composite of the systems listed, which may live on different threads. + * - `create({ systems: [move, collide], after: [input] })`: a composite with a name or constraints. + * + * Each run passes a composite's members one input, with every field of theirs. Throws if a system names a key that + * isn't registered as its kind, lists a key in both `read` and `write`, writes a reference, a lookup reads a component + * it writes or goes through an index keyed by one, or reads through an index what not every entity it holds has, its + * rows are ordered by an index it isn't sorted or keyed by what it writes, its object components and resources live on + * different threads, or its reductions, conditions, `up`, `order` or placement can't hold, or it's an observer and + * writes, reduces, takes input, is a kernel, has `up` or is ordered; or if a composite's member isn't a system a + * constructor returned, or two members declare an input field with different schemas. A composite's observer members + * run after its sync point, ordered with nothing. + * + * One argument, a body or a composite, is one overload, so a mistake in it is reported where it is rather than as no + * overload matching. + */ +export function create(declaration: D & Validate & OneThread, ThreadedBy>, body: Body): System>; +export function create(only: C & Checked): C extends Alone ? System : System>>; +export function create(first: Declaration | Leaf["body"] | readonly System[] | CompositeDeclaration, second?: Leaf["body"]): System { + // The plugin gives its id to the call's first argument: the declaration, the members, or the body made into a + // system alone. + const id = takeIdOfDeclaration(first); + if (isList(first)) return compose({ systems: first }, id); + if (typeof first !== "function" && second === undefined && "systems" in first) return compose(first, id); + const declaration: Declaration = typeof first === "function" ? {} : first; + const body = typeof first === "function" ? first : second; + if (body === undefined) throw new Error("A system needs a body, and a composite its systems."); + const system: Leaf & System = { kind: "system", name: nameOf(declaration.name, id, "system"), declaration, body }; + checkLeaf(system); + claimSystem(system, id); + return system; +} + +const isList = (value: unknown): value is readonly System[] => Array.isArray(value); + +const compose = (declaration: CompositeDeclaration, id: string | undefined): System => { + const system: Composite & System = { kind: "composite", name: nameOf(declaration.name, id, "system"), declaration }; + checkComposite(system); + claimSystem(system, id); + return system; +}; diff --git a/packages/ecs/src/interface/system/declaration.ts b/packages/ecs/src/interface/system/declaration.ts new file mode 100644 index 000000000..1c4380796 --- /dev/null +++ b/packages/ecs/src/interface/system/declaration.ts @@ -0,0 +1,65 @@ +import type { Index } from '../indexes/index-type.js'; +import type { Resource } from '../resource/resource.js'; +import type { Schema } from '../schema/schema.js'; +import type { ComponentAccess, ResourceAccess, SystemLookup, Up } from './access.js'; +import type { Name } from './name.js'; +import type { Shader } from './render.js'; +import type { System } from './system.js'; +import type { OperatorOf, Reduced } from '../resource/reduction.js'; + +/** + * A system never declares its thread: it runs on the one its object components and resources live on, those it + * reads, writes or filters on by change, through `components`, `lookup`, `up` and `resources` but `has` and `not`. + * Without them, a kernel runs on the kernel pool and any other system on `"world"`. + */ +export type Declaration = { + readonly name?: Name; + /** + * Compiled to a kernel, which the build rejects if the body leaves the kernel subset. Values in `components` and + * `lookup` must be structs and resources read-only. Without it, the system runs as written, on its thread. + * - `"cpu"`: runs on the kernel pool. - `"gpu"`: also has no lookups, change filters, commands or loops, and only + * 4-byte scalar values, its `up` included. It runs as a compute shader in a world with a GPU, and as a `"cpu"` + * kernel in one without. Every matched row counts as changed in what it writes. + */ + readonly kernel?: "cpu" | "gpu"; + /** The entities it runs over, and what it reads and writes of theirs. Omitted for a system that runs once per run. */ + readonly components?: ComponentAccess; + readonly lookup?: { readonly [name: string]: SystemLookup }; + /** + * Runs rows in depth order through `through`, so each reads its parent's `read` values after the parent ran, as + * the context's `up`, or null for a row without a parent. With change filters, a row also passes when its parent, + * or what it reads of its parent, changed since the system last ran, or it moved to another archetype, as removing + * its parent moves it. Rows on or under a parent cycle don't run. + */ + readonly up?: Up; + /** + * Runs the rows it matches that this sorted index holds, in key order and then entity order; with change filters, + * the rows that pass, in that order. Commands, reductions and side effects follow it. Like a lookup through the + * index, it reads the index's key parts and doesn't write them. Not with `up`. + */ + readonly order?: Index<{ readonly sorted: true }>; + readonly resources?: ResourceAccess; + /** The resources its body contributes to, each with the operator its `reduce` trait declares. */ + readonly reduce?: { readonly [K in Reduced]?: OperatorOf }; + /** The object of named fields passed to each run. A system with input can't use `added` or `changed`. */ + readonly input?: Extract; + /** + * The input field naming the one entity it runs on: its body runs once, on that entity's row, when the entity is + * alive and matches `components`, and not at all otherwise. Needs `components`; not with `kernel`, `up`, `order` + * or `reduce`. + */ + readonly target?: string; + /** Makes this a render system (see `render`): its body records GPU work over the GPU copy of the components it names. */ + readonly shader?: Shader; + /** Hard constraint. */ + readonly before?: readonly System[]; + /** Hard constraint. */ + readonly after?: readonly System[]; + /** Runs inside the span of the systems listed: after what they follow and before what follows them. */ + readonly during?: readonly System[]; + /** + * Runs after the last sync point of each run and tick (a tick is a run of the added composite), reading the settled + * state; changes nothing; never ordered; doesn't keep the loop ticking. + */ + readonly observer?: true; +}; diff --git a/packages/ecs/src/interface/system/definition.ts b/packages/ecs/src/interface/system/definition.ts new file mode 100644 index 000000000..588f4f0e0 --- /dev/null +++ b/packages/ecs/src/interface/system/definition.ts @@ -0,0 +1,5 @@ +import type { Composite } from './composite.js'; +import type { Leaf } from './leaf.js'; + +/** What the implementation knows of a system: the value a constructor returned, with its kind, declaration and body. */ +export type Definition = Leaf | Composite; diff --git a/packages/ecs/src/interface/system/index.ts b/packages/ecs/src/interface/system/index.ts new file mode 100644 index 000000000..dba7ebd75 --- /dev/null +++ b/packages/ecs/src/interface/system/index.ts @@ -0,0 +1,3 @@ +export type { Commands } from './commands.js'; +export * from './create.js'; +export { render, type RenderContext, type RenderTable } from './render.js'; diff --git a/packages/ecs/src/interface/system/input.ts b/packages/ecs/src/interface/system/input.ts new file mode 100644 index 000000000..68b2e4442 --- /dev/null +++ b/packages/ecs/src/interface/system/input.ts @@ -0,0 +1,28 @@ +import type { Schema } from '../schema/schema.js'; +import type { System } from './system.js'; + +/** + * The input a system declares: a value of its `input` schema, or `undefined` without one; `unknown` for `any`, as an + * overload's declaration erases to, since every schema's value type is too deep to compute. + */ +export type InputOf = 0 extends 1 & D ? unknown : D extends { readonly input: infer A extends Schema } ? Schema.ToType : undefined; + +/** What `run` takes after a system: nothing, or one value. */ +export type Input = [I] extends [undefined] ? [] : [input: I]; + +/** A member's input fields, or `unknown` without input. */ +type Fields = S extends System ? [Exclude] extends [never] ? unknown : Exclude : unknown; + +type Merged = T extends readonly [infer S, ...infer R] ? Merged> : A; + +/** The input a composite of `T` takes: every field of its members' inputs, or `undefined` when none has input. */ +export type CompositeInputOf = unknown extends Merged ? undefined : { [K in keyof Merged]: Merged[K] }; + +/** The fields of `M` whose type differs from the one the composite's input `I` gives them. */ +type Conflicts = { [K in keyof M]-?: [Exclude] extends [Exclude] ? never : K }[keyof M]; + +type Checked = [Conflicts, I>] extends [never] ? S + : `Input field "${Extract, I>, string>}" has a different type in another member of this composite.`; + +/** Members whose input has a field of a different type than another member's. */ +export type CheckMembers = { readonly [P in keyof T]: Checked> }; diff --git a/packages/ecs/src/interface/system/leaf.ts b/packages/ecs/src/interface/system/leaf.ts new file mode 100644 index 000000000..c7a361212 --- /dev/null +++ b/packages/ecs/src/interface/system/leaf.ts @@ -0,0 +1,13 @@ +import type { Declaration } from './declaration.js'; +import type { Name } from './name.js'; + +/** + * A system that runs a body: `"render"` for one made by `render`, `"system"` otherwise. Its body's parameters are + * checked where it's created, so a leaf holds any body. + */ +export type Leaf = { + readonly kind: "system" | "render"; + readonly name: Name; + readonly declaration: Declaration; + readonly body: (...parameters: never[]) => void; +}; diff --git a/packages/ecs/src/interface/system/name.ts b/packages/ecs/src/interface/system/name.ts new file mode 100644 index 000000000..50cbfa06d --- /dev/null +++ b/packages/ecs/src/interface/system/name.ts @@ -0,0 +1,6 @@ +/** + * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so + * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without + * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. + */ +export type Name = string; diff --git a/packages/ecs/src/interface/system/property.ts b/packages/ecs/src/interface/system/property.ts new file mode 100644 index 000000000..cb157e8a9 --- /dev/null +++ b/packages/ecs/src/interface/system/property.ts @@ -0,0 +1,4 @@ + +export type Property = T extends { readonly [P in K]?: infer V } ? V : undefined; + +export type Listed = Property extends readonly (infer E)[] ? E : never; diff --git a/packages/ecs/src/interface/system/render.ts b/packages/ecs/src/interface/system/render.ts new file mode 100644 index 000000000..a3832a706 --- /dev/null +++ b/packages/ecs/src/interface/system/render.ts @@ -0,0 +1,81 @@ +import type { Component } from '../component/component.js'; +import { takeIdOfDeclaration } from '../registry/ids.js'; +import { nameOf } from '../registry/items.js'; +import { claimSystem } from '../registry/program.js'; +import type { Condition } from '../resource/condition.js'; +import type { Resource } from '../resource/resource.js'; +import type { ThreadOfResource } from '../resource/value.js'; +import { gpuDevice } from '../resources/gpu-device.js'; +import type { Body } from './body.js'; +import { accessorsOf } from '../gpu/accessors.js'; +import { checkLeaf } from './check.js'; +import type { Leaf } from './leaf.js'; +import type { Name } from './name.js'; +import type { StructComponent } from './struct.js'; +import type { System } from './system.js'; +import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; + +/** + * How a render system's shaders reach its components: `accessors` names a WGSL function for each component it reads, + * generated by `Gpu.wgsl` at bind group `group` (0 by default). + */ +export type Shader = { readonly group?: number; readonly accessors: { readonly [name: string]: StructComponent } }; + +/** One group of matching rows, `0` to `count`. `bind` sets the world's bind group for it on a pass. */ +export type RenderTable = { + readonly count: number; + readonly bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => void; +}; + +/** + * What a render body records with. `encoder` is the run's: the world submits it, after the run's GPU kernels and + * uploads, when the run ends. Acquire canvas textures inside the body. + */ +export type RenderContext = { + readonly device: GPUDevice; + readonly encoder: GPUCommandEncoder; + readonly tables: readonly RenderTable[]; +}; + +type RenderDeclaration = { + readonly name?: Name; + /** It reads the components its accessors name. */ + readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + /** It runs only while `gpuDevice` is set, as if `is` listed it. */ + readonly resources?: { readonly read?: readonly Resource.Key[]; readonly is?: readonly Condition[]; readonly not?: readonly Condition[] }; + readonly shader: Shader; + readonly before?: readonly System[]; + readonly after?: readonly System[]; + readonly during?: readonly System[]; +}; + +/** A render declaration with the components its accessors name as `components.read`. */ +type Rendered = D & { readonly components: { readonly read: readonly StructComponent[] } }; + +/** + * Declares a render system: it runs once per run on `gpuDevice`'s thread, in schedule order, and records GPU work over + * the GPU copy of the components its accessors name, which the world uploads first as needed. It runs only while the + * world has a `gpuDevice`. Throws as `create` does, and for an accessor its shader can't read on the GPU. + */ +export const render = ( + declaration: D & OneThread | ThreadOfResource, ThreadedBy>, + body: Body>, +): System => { + const id = takeIdOfDeclaration(declaration); + const name = nameOf(declaration.name, id, "system"); + const system: Leaf & System = { + kind: "render", + name, + declaration: { + ...declaration, + components: { ...declaration.components, read: [...new Set(Object.values(declaration.shader.accessors))] }, + resources: { ...declaration.resources, is: [...new Set([...declaration.resources?.is ?? [], gpuDevice])] }, + }, + body, + }; + checkLeaf(system); + const accessors = accessorsOf(declaration.shader); + if (typeof accessors === "string") throw new Error(`System "${name}" can't render: ${accessors}.`); + claimSystem(system, id); + return system; +}; diff --git a/packages/ecs/src/interface/system/struct.ts b/packages/ecs/src/interface/system/struct.ts new file mode 100644 index 000000000..9da1c1080 --- /dev/null +++ b/packages/ecs/src/interface/system/struct.ts @@ -0,0 +1,9 @@ +import type { Component } from '../component/component.js'; +import type { Resource } from '../resource/resource.js'; +import type { IsStruct } from '../schema/is-struct.js'; + +/** Components whose values are structs, which kernels read and write. */ +export type StructComponent = { [K in Component.Key]: IsStruct> extends true ? K : never }[Component.Key]; + +/** Resources whose values are structs, which kernels read. */ +export type StructResource = { [K in Resource.Key]: IsStruct> extends true ? K : never }[Resource.Key]; diff --git a/packages/ecs/src/interface/system/system.ts b/packages/ecs/src/interface/system/system.ts new file mode 100644 index 000000000..8527cc7f2 --- /dev/null +++ b/packages/ecs/src/interface/system/system.ts @@ -0,0 +1,9 @@ +declare const input: unique symbol; + +/** + * A system, the value a System constructor returned: identified by that value and labeled by its name. `run` takes it + * with a value of type `I`, or with nothing when `I` is `undefined`. + */ +export type System = { readonly name: string; readonly [input]?: I }; + +export * as System from './index.js'; diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts new file mode 100644 index 000000000..9da8ccd47 --- /dev/null +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -0,0 +1,460 @@ +import { Resource } from '../resource/resource.js'; +import { Component } from '../component/component.js'; +import { Entity } from '../entity/entity.js'; +import { System } from './system.js'; +import type { Index } from '../indexes/index-type.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +export const position = Symbol("systemTest/position"); +const velocity = Symbol("systemTest/velocity"); +const target = Symbol("systemTest/target"); +const player = Symbol('systemTest/player'); +export const time = Symbol("systemTest/time"); +const score = Symbol("systemTest/score"); + +declare module '@adobe/ecs' { + export interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + [target]: { type: Entity }; + [player]: { type: true }; + } +} +Component.register(position, { type: { type: "number" } }); +Component.register(velocity, { type: { type: "number" } }); +Component.register(target, { type: Entity.schema }); +Component.register(player, { type: { const: true } }); +const flagged = Symbol("systemTest/flagged"); +const bounds = Symbol("systemTest/bounds"); +export const total = Symbol("systemTest/total"); +declare module '@adobe/ecs' { + export interface Resources { + [time]: { type: number }; + [score]: { type: number }; + [flagged]: { type: boolean; reduce: "|" }; + [bounds]: { type: readonly [number, number, number]; reduce: "min" }; + [total]: { type: number; reduce: "+" }; + } +} +Resource.register(bounds, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, default: [Infinity, Infinity, Infinity] }, reduce: "min" }); +Resource.register(time, { type: { type: "number", default: 0 } }); +Resource.register(score, { type: { type: "number", default: 0 } }); +Resource.register(flagged, { type: { type: "boolean", default: false }, reduce: "|" }); +Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); + +// Row: read values are read-only, write values writable, others absent. +export const integrate = System.create({ + name: "test/integrate", + components: { read: [velocity], write: [position], has: [player] }, + resources: { read: [time] }, +}, (row, { resources: { [time]: delta } }) => { + row[position] += row[velocity] * delta; + const entity: Entity = row.entity; + // @ts-expect-error read values are read-only + row[velocity] = 0; + // @ts-expect-error entity is read-only + row.entity = entity; + // @ts-expect-error components outside read and write are not in the row + void row[player]; +}); + +// Write grants read: a component in write is readable, and listing it in read as well is rejected. +System.create({ + name: "test/integrate", + // @ts-expect-error write already makes it readable + components: { read: [position], write: [position] }, +}, () => {}); + +// Lookups: read-only values by entity, or null. +export const follow = System.create({ + name: "test/follow", + components: { read: [target, position], write: [velocity] }, + lookup: { targets: { read: [position] } }, + after: [integrate], +}, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + if (other !== null) { + row[velocity] = other[position] - row[position]; + // @ts-expect-error lookup values are read-only + other[position] = 0; + } + // @ts-expect-error a lookup returns null for a missing entity + void targets(row[target])[position]; + // @ts-expect-error a lookup without changed has no changed + void targets(row[target])?.changed; +}); + +// A lookup by entity with `changed`: its row also says whether what `changed` names may have changed since the system +// last ran. +System.create({ + name: "test/followChanged", + components: { read: [target], write: [velocity] }, + lookup: { targets: { read: [position], changed: [position] }, players: { has: [player], changed: [player] } }, +}, (row, { lookup: { targets, players } }) => { + const other = targets(row[target]); + if (other !== null) { + check>(); + check>(); + // @ts-expect-error changed is read-only + other.changed = false; + } + const found = players(row[target]); + if (found !== null) check>(); +}); + +System.create({ + name: "test/followChanged", + components: { read: [target], write: [velocity] }, + // @ts-expect-error a lookup by entity's changed names what it reads or has + lookup: { targets: { read: [position], changed: [velocity] } }, +}, () => {}); + +// Input: typed from its schema. +export const move = System.create({ + name: "test/move", + input: { type: "object", properties: { distance: { type: "number" } } }, + components: { has: [player], write: [position] }, +}, (row, { input }) => { + row[position] += input.distance; + // @ts-expect-error input has its schema's type + void input.missing; +}); + +// A system without components takes only its context; resource writes are writable, and written ones readable. +export const tally = System.create({ + name: "test/tally", + resources: { read: [time], write: [score] }, +}, ({ resources, commands }) => { + resources[score] += resources[time]; + // @ts-expect-error read resources are read-only + resources[time] = 0; + const spawned: Entity = commands.spawn([position, player])({ [position]: 0, [player]: true }); + // @ts-expect-error spawn values must match the component type + commands.spawn([position])({ [position]: "zero" }); + commands.remove(spawned, velocity); +}); + +// The context holds only what the declaration declares. +System.create({ name: "test/tally" }, context => { + // @ts-expect-error no resources declared + void context.resources; + // @ts-expect-error no lookups declared + void context.lookup; + // @ts-expect-error no reductions declared + void context.reduce; + // @ts-expect-error no input declared + void context.input; + void context.commands; +}); + +// @ts-expect-error a system without components has no row +System.create({ name: "test/tally" }, (row: object, context: object) => { void [row, context]; }); + +System.create({ + name: "test/tally", + // @ts-expect-error write already makes a resource readable + resources: { read: [score], write: [score] }, +}, () => {}); + +System.create({ + name: "test/integrate", + // @ts-expect-error ordering takes systems + after: ["test/unregistered"], +}, () => {}); + +System.create({ + name: "test/move", + input: { type: "object", properties: { distance: { type: "number" } } }, + // @ts-expect-error a system with input can't use change filters + components: { changed: [position] }, +}, () => {}); + +export const main = System.create({ name: "test/main", systems: [integrate, follow, tally] }); +const moving = System.create({ name: "test/moving", systems: [move] }); + +// A composite takes its members' input. +check>>(); + +// A system is its name and the input `run` takes with it. +check>(); +check>>(); +check>(); + +const label = Symbol("systemTest/label"); +const path = Symbol("systemTest/path"); +const vector = Symbol("systemTest/vector"); +const marks = Symbol("systemTest/marks"); +declare module '@adobe/ecs' { + export interface Components { + [label]: { type: string }; + [path]: { type: readonly number[] }; + [vector]: { type: readonly [number, number, number] }; + [marks]: { type: ReadonlySet }; + } +} +Component.register(label, { type: { type: "string" } }); +Component.register(marks, { type: { type: "set", items: { type: "number" } } }); +Component.register(path, { type: { type: "array", items: { type: "number" } } }); +Component.register(vector, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 } }); + +// Kernels: linear values (numbers, booleans, entities, fixed tuples), read-only resources. +System.create({ + name: "test/integrate", + kernel: "cpu", + components: { read: [velocity, vector, player, target], write: [position], has: [label] }, + resources: { read: [time] }, +}, () => {}); + +System.create({ + name: "test/integrate", + kernel: "cpu", + // @ts-expect-error kernel values must be linear + components: { read: [label] }, +}, () => {}); + +System.create({ + name: "test/integrate", + kernel: "cpu", + // @ts-expect-error kernel values can't be variable-length arrays + components: { write: [path] }, +}, () => {}); + +System.create({ + name: "test/integrate", + kernel: "cpu", + // @ts-expect-error kernel values can't be sets or maps + components: { read: [marks] }, +}, () => {}); + +System.create({ + name: "test/integrate", + kernel: "cpu", + // @ts-expect-error kernel lookups must read linear values + lookup: { labels: { read: [label] } }, +}, () => {}); + +declare const byTarget: Index<{ readonly key: readonly [typeof target] }>; + +// A kernel's lookup by entity matches has and not, as any system's does. +System.create({ + name: "test/followPlayers", + kernel: "cpu", + components: { read: [target], write: [position] }, + lookup: { players: { read: [position], has: [player], not: [velocity] } }, +}, (row, { lookup }) => { + const found = lookup.players(row[target]); + if (found !== null) row[position] = found[position]; +}); + +System.create({ + name: "test/followPlayers", + kernel: "cpu", + // @ts-expect-error an index lookup matches what its index holds, so it takes no has + lookup: { byTarget: { index: byTarget, read: [position], has: [player] } }, +}, () => {}); + +System.create({ + name: "test/tally", + kernel: "cpu", + // @ts-expect-error kernels can't write resources + resources: { write: [score] }, +}, () => {}); + +// GPU kernels: everything kernels can't do, and no lookups, change filters or commands. +System.create({ + name: "test/integrate", + kernel: "gpu", + components: { read: [velocity], write: [position] }, + resources: { read: [time] }, +}, row => { + row[position] = row[velocity]; +}); + +System.create({ + name: "test/integrate", + kernel: "gpu", + // @ts-expect-error GPU kernel values must be linear too + components: { read: [label] }, +}, () => {}); + +System.create({ + name: "test/follow", + kernel: "gpu", + components: { read: [target], write: [position] }, + // @ts-expect-error GPU kernels can't use lookups + lookup: { targets: { read: [position] } }, +}, () => {}); + +System.create({ + name: "test/integrate", + kernel: "gpu", + // @ts-expect-error GPU kernels can't use change filters + components: { write: [position], changed: [velocity] }, +}, () => {}); + +System.create({ + name: "test/integrate", + kernel: "gpu", + components: { read: [velocity], write: [position] }, +}, (row, context) => { + // @ts-expect-error GPU kernels can't issue commands + context.commands.despawn(row.entity); +}); + +System.create({ + name: "test/integrate", + // @ts-expect-error a kernel is "cpu" or "gpu" + kernel: true, +}, () => {}); + +// Reductions: each reduced resource is a function contributing a value of its type, with its trait's operator repeated. +System.create({ + name: "test/tally", + components: { read: [velocity, vector], changed: [velocity] }, + reduce: { [total]: "+", [bounds]: "min", [flagged]: "|" }, +}, (row, { reduce }) => { + reduce[total](row[velocity]); + reduce[bounds](row[vector]); + reduce[flagged](row[velocity] > 1); + // @ts-expect-error a contribution has the resource's type + reduce[bounds](1); + // @ts-expect-error a reduced resource can't be read + void (reduce[total] + 1); +}); + +System.create({ + name: "test/tally", + // @ts-expect-error the operator must equal the resource's reduce trait + reduce: { [total]: "max" }, +}, () => {}); + +System.create({ + name: "test/tally", + // @ts-expect-error only resources with a reduce trait can be reduced + reduce: { [score]: "+" }, +}, () => {}); + +System.create({ + name: "test/tally", + // @ts-expect-error a reduced resource is never written + resources: { write: [total] }, +}, () => {}); + +System.create({ + name: "test/tally", + // @ts-expect-error a contributor doesn't read what it reduces into + resources: { read: [total] }, + reduce: { [total]: "+" }, +}, () => {}); + +System.create({ + name: "test/integrate", + kernel: "gpu", + components: { read: [velocity] }, + // @ts-expect-error GPU kernels can't reduce yet + reduce: { [total]: "+" }, +}, () => {}); + + +// Render systems: the accessors name the components they read. +System.render({ name: "test/draw", components: { has: [player] }, resources: { read: [time] }, shader: { accessors: { place: position, speed: velocity } } }, (gpu, { resources }) => { + void [gpu.encoder, resources[time]]; +}); +System.render({ name: "test/draw", shader: { accessors: { place: position } } }, (_, context) => { + // @ts-expect-error a render body issues no commands + void context.commands; +}); +System.render({ + name: "test/draw", + // @ts-expect-error a render system reads the components its accessors name + components: { read: [position] }, + shader: { accessors: { place: position } }, +}, () => {}); +// A pass helper several render systems share declares its context and tables by name. +const drawAll = (gpu: System.RenderContext, pass: GPURenderPassEncoder): void => { + for (const table of gpu.tables) drawTable(table, pass); +}; +const drawTable = (table: System.RenderTable, pass: GPURenderPassEncoder): void => { + table.bind(pass); + pass.draw(6, table.count); +}; +System.render({ name: "test/drawShared", shader: { accessors: { place: position } } }, gpu => { + const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); + drawAll(gpu, pass); + pass.end(); +}); + +// Names are optional: a declaration may leave it out, and a system that declares nothing is just its body. +export const unnamed = System.create({ components: { write: [position] }, resources: { read: [time] } }, (row, { resources }) => { + row[position] += resources[time]; +}); +check>(); +export const unnamedInput = System.create({ input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input }) => { + const count: number = input.count; + void count; +}); +check>>(); +export const bodyOnly = System.create(({ commands }) => { + commands.spawn([position])({ [position]: 0 }); +}); +check>(); +// @ts-expect-error a body alone declares no resources +System.create(({ resources }) => { void resources; }); +// @ts-expect-error a body alone runs once per run, so it has no row +System.create((row: { readonly entity: Entity }) => { void row; }); +export const unnamedAll = System.create([unnamed, bodyOnly]); +check>(); +System.render({ shader: { accessors: { place: position } } }, gpu => { void gpu.encoder; }); + +// Observer systems read what a sync point left, after it: rows, resources, lookups and change filters, and no commands. +export const watchPositions = System.create({ + name: "test/watchPositions", + observer: true, + components: { read: [position], changed: [position] }, + resources: { read: [time] }, + lookup: { targets: { read: [velocity] } }, +}, (row, context) => { + const read: number = row[position] + context.resources[time]; + void [read, context.lookup.targets(row.entity)]; + // @ts-expect-error an observer system reads only + row[position] = 0; + // @ts-expect-error an observer system issues no commands + void context.commands; +}); +check>(); +export const watchTime = System.create({ observer: true, resources: { read: [time], changed: [time] } }, ({ resources }) => { void resources[time]; }); +// A composite may hold observer members, which run after its sync point. +export const withWatch = System.create([integrate, watchTime]); +check>(); +// @ts-expect-error observer is true or left out +System.create({ observer: false, resources: { read: [time] } }, () => {}); +// @ts-expect-error an observer system writes no components +System.create({ observer: true, components: { write: [position] } }, () => {}); +// @ts-expect-error an observer system writes no resources +System.create({ observer: true, resources: { write: [score] } }, () => {}); +// @ts-expect-error an observer system reduces into nothing +System.create({ observer: true, components: { read: [velocity] }, reduce: { [total]: "+" } }, () => {}); +// @ts-expect-error an observer system takes no input +System.create({ observer: true, input: { type: "object", properties: { count: { type: "integer" } } } }, () => {}); +// @ts-expect-error an observer system isn't a kernel +System.create({ observer: true, kernel: "cpu", components: { read: [position] } }, () => {}); +// @ts-expect-error an observer system is ordered with nothing +System.create({ observer: true, resources: { read: [time] }, after: [integrate] }, () => {}); +// @ts-expect-error an observer system is ordered with nothing +System.create({ observer: true, resources: { read: [time] }, before: [integrate] }, () => {}); +// @ts-expect-error an observer system is ordered with nothing +System.create({ observer: true, resources: { read: [time] }, during: [integrate] }, () => {}); + +// A system on one entity: its input names the entity, and its body runs on that entity's row. +const onOne = { type: "object", properties: { entity: Entity.schema, by: { type: "number" } } } as const; +export const nudge = System.create({ input: onOne, target: "entity", components: { write: [position] } }, (row, { input }) => { + row[position] += input.by; +}); +// @ts-expect-error its target is an entity field of its input +System.create({ input: onOne, target: "by", components: { write: [position] } }, () => {}); +// @ts-expect-error it runs on a row, so it needs components +System.create({ input: onOne, target: "entity" }, () => {}); +// @ts-expect-error not a kernel +System.create({ input: onOne, target: "entity", kernel: "cpu", components: { write: [position] } }, () => {}); diff --git a/packages/ecs/src/interface/system/threads.ts b/packages/ecs/src/interface/system/threads.ts new file mode 100644 index 000000000..35439e8b2 --- /dev/null +++ b/packages/ecs/src/interface/system/threads.ts @@ -0,0 +1,46 @@ +import type { Component } from '../component/component.js'; +import type { ThreadOfComponent } from '../component/value.js'; +import type { Resource } from '../resource/resource.js'; +import type { ThreadOfResource } from '../resource/value.js'; +import type { Index } from '../indexes/index-type.js'; +import type { Listed, Property } from './property.js'; +import type { KeyOf } from './values.js'; + +type Lookups = Property; +type LookupReads = { [L in keyof Lookups]: Listed[L], "read"> }[keyof Lookups]; +type LookupKeys = { [L in keyof Lookups]: Lookups[L] extends { readonly index: Index } ? KeyOf[number] : never }[keyof Lookups]; +type OrderKeys = Property extends Index ? KeyOf[number] : never; +type Components = Property; +type Resources = Property; + +/** The components that place a declaration: those it reads, writes or filters on by change, and the keys it looks up and orders by. */ +type Placing = + | Listed, "read"> | Listed, "write"> | Listed, "added"> | Listed, "changed"> + | LookupReads | LookupKeys | Listed, "read"> | OrderKeys; + +/** The resources that place a declaration: those it reads, writes, filters on by change, or reads as a condition. */ +type PlacingResources = + Listed, "read"> | Listed, "write"> | Listed, "changed"> | Listed, "is">; + +/** The threads of the object components and resources a declaration uses. */ +export type ThreadsOf = ThreadOfComponent & Component.Key> | ThreadOfResource & Resource.Key>; + +/** Each object component and resource a declaration uses, by key, with the thread it lives on. */ +export type ThreadedBy = { + readonly [K in (Placing & Component.Key) | (PlacingResources & Resource.Key) as [Thread] extends [never] ? never : K]: Thread; +}; +type Thread = K extends Component.Key ? ThreadOfComponent : K extends Resource.Key ? ThreadOfResource : never; + +type IsUnion = T extends unknown ? ([U] extends [T] ? false : true) : never; + +/** + * Requires a property no declaration has when `T` holds more than one thread, so the declaration fails to compile; its + * value names each key with its thread, `ThreadedBy` of the declaration. + */ +export type OneThread = true extends IsUnion ? { readonly "error: its object components and resources live on different threads": { readonly [K in keyof Keys]: Keys[K] } } : {}; + +/** + * Whether `T`, the thread a declaration's objects live on, is another than the world's, which a joined thread may fill; + * false for the threads of every declaration, which hold the world's. + */ +export type Elsewhere = [T] extends [never] ? false : "world" extends T ? false : true; diff --git a/packages/ecs/src/interface/system/threads.type-test.ts b/packages/ecs/src/interface/system/threads.type-test.ts new file mode 100644 index 000000000..0b1aca33e --- /dev/null +++ b/packages/ecs/src/interface/system/threads.type-test.ts @@ -0,0 +1,114 @@ +import { Resource } from '../resource/resource.js'; +import { Component } from '../component/component.js'; +import { Finalizer } from '../finalizer/finalizer.js'; +import { System } from './system.js'; +import type { ThreadedBy } from './threads.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const node = Symbol("threadsTypeTest/node"); +const label = Symbol("threadsTypeTest/label"); +const audio = Symbol("threadsTypeTest/audio"); +const position = Symbol("threadsTypeTest/position"); +const document = Symbol("threadsTypeTest/document"); +const settings = Symbol("threadsTypeTest/settings"); +const time = Symbol("threadsTypeTest/time"); +const tags = Symbol("threadsTypeTest/tags"); + +declare module '@adobe/ecs' { + export interface Threads { + threadsTypeTest: true; + } + export interface Components { + [node]: { type: HTMLElement; thread: "main" }; + [label]: { type: string }; + [audio]: { type: AudioBuffer; thread: "threadsTypeTest" }; + [position]: { type: readonly [number, number] }; + [tags]: { type: readonly string[] }; + } + export interface Resources { + [document]: { type: Document | null; thread: "main" }; + [settings]: { type: { readonly name: string } | null }; + [time]: { type: number }; + } +} +Resource.register(settings, { type: { anyOf: [{ type: "object", properties: { name: { type: "string" } } }, { type: "null" }], default: null } }); +Component.register(node, { thread: "main" }); +Component.register(label, { type: { type: "string" } }); +Component.register(audio, { thread: "threadsTypeTest" }); +Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); +Component.register(tags, { type: { type: "array", items: { type: "string" } } }); +Resource.register(document, { thread: "main" }); +Resource.register(time, { type: { type: "number", default: 0 } }); + +// A system runs on the thread of the object components and resources it uses; strings and structs go with any. +System.create({ name: "threadsTypeTest/render", components: { read: [position], write: [node] }, resources: { read: [time, document] } }, () => {}); +System.create({ name: "threadsTypeTest/play", components: { read: [audio, position] } }, () => {}); +System.create({ name: "threadsTypeTest/label", components: { write: [label] }, resources: { is: [settings] } }, () => {}); +// Presence-only lists don't place a system. +System.create({ name: "threadsTypeTest/count", components: { read: [label], has: [node], not: [audio] }, resources: { read: [time] } }, () => {}); +System.create({ name: "threadsTypeTest/paused", components: { read: [node] }, resources: { not: [settings] } }, () => {}); +System.create({ name: "threadsTypeTest/caption", components: { read: [label], write: [node] } }, () => {}); +System.create({ name: "threadsTypeTest/captionChanged", components: { read: [node], changed: [label] }, lookup: { labels: { read: [label] } } }, () => {}); + +// @ts-expect-error a system's object components live on one thread +System.create({ name: "threadsTypeTest/both", components: { read: [node, tags] } }, () => {}); +// @ts-expect-error writes and change filters place it too +System.create({ name: "threadsTypeTest/changed", components: { write: [tags], changed: [audio] } }, () => {}); +// @ts-expect-error a lookup's reads place it too +System.create({ name: "threadsTypeTest/lookup", components: { read: [tags] }, lookup: { nodes: { read: [node] } } }, () => {}); +// @ts-expect-error resources and components live on one thread +System.create({ name: "threadsTypeTest/mixed", components: { read: [audio] }, resources: { read: [document] } }, () => {}); +// @ts-expect-error a condition it reads places it +System.create({ name: "threadsTypeTest/condition", components: { read: [node] }, resources: { is: [settings] } }, () => {}); + +// Render systems run on gpuDevice's thread, "world". +System.render({ name: "threadsTypeTest/draw", resources: { read: [settings] }, shader: { accessors: { place: position } } }, () => {}); +// @ts-expect-error a render system's resources live with gpuDevice +System.render({ name: "threadsTypeTest/drawNode", resources: { read: [document] }, shader: { accessors: { place: position } } }, () => {}); + +// A composite's members may live on different threads. +const render = System.create({ name: "threadsTypeTest/renderMember", components: { write: [node] } }, () => {}); +const play = System.create({ name: "threadsTypeTest/playMember", components: { write: [audio] } }, () => {}); +export const frame = System.create({ name: "threadsTypeTest/frame", systems: [render, play] }); + +// Finalizers too run on one thread. +Finalizer.create({ name: "threadsTypeTest/release", components: { read: [node] }, resources: { read: [document] } }, () => {}); +// @ts-expect-error a finalizer's object components and resources live on one thread +Finalizer.create({ name: "threadsTypeTest/releaseBoth", components: { read: [node, audio] } }, () => {}); +Finalizer.create({ name: "threadsTypeTest/releaseCaption", components: { read: [node, label] } }, () => {}); + +// A finalizer on another thread than the world's, which a joined thread may fill, runs there after the sync point: it +// issues no commands, writes no resources and looks up nothing. +Finalizer.create({ name: "threadsTypeTest/detach", components: { read: [node, position] }, resources: { read: [document, time] } }, (row, context) => { + row[node].remove(); + void [row[position], context.resources[document], context.resources[time]]; + // @ts-expect-error a finalizer on another thread issues no commands + void context.commands; +}); +Finalizer.create({ name: "threadsTypeTest/silence", components: { read: [audio] } }, (row, context) => { + void row[audio]; + // @ts-expect-error a finalizer on another thread issues no commands + void context.commands; +}); +// @ts-expect-error a finalizer on another thread writes no resources +Finalizer.create({ name: "threadsTypeTest/detachWriting", components: { read: [node] }, resources: { write: [time] } }, () => {}); +// @ts-expect-error a finalizer writing another thread's resource is on that thread +Finalizer.create({ name: "threadsTypeTest/documentWriting", components: { read: [position] }, resources: { write: [document] } }, () => {}); +// @ts-expect-error a finalizer on another thread looks up nothing +Finalizer.create({ name: "threadsTypeTest/detachLooking", components: { read: [node] }, lookup: { places: { read: [position] } } }, () => {}); +// On the world's thread, structs and its objects alike, it does. +Finalizer.create({ name: "threadsTypeTest/forget", components: { read: [label, position] }, resources: { write: [time] }, lookup: { labels: { read: [label] } } }, (row, { commands, resources, lookup }) => { + resources[time] = lookup.labels(row.entity)?.[label].length ?? 0; + commands.despawn(row.entity); +}); +// A changes finalizer runs on the world's thread: watching another thread's objects, or reading its resources, doesn't compile. +Finalizer.changes({ name: "threadsTypeTest/save", watch: [label, position], resources: { read: [time] } }, (_, { commands }) => { void commands; }); +// @ts-expect-error a changes finalizer watches no other thread's objects +Finalizer.changes({ name: "threadsTypeTest/saveNodes", watch: [node] }, () => {}); +// @ts-expect-error a changes finalizer reads no other thread's resources +Finalizer.changes({ name: "threadsTypeTest/saveDocument", resources: { read: [document] } }, () => {}); + +// The error names each object component and resource with its thread; data places nothing. +check, { readonly [audio]: "threadsTypeTest"; readonly [document]: "main" }>>(); diff --git a/packages/ecs/src/interface/system/up.type-test.ts b/packages/ecs/src/interface/system/up.type-test.ts new file mode 100644 index 000000000..3158fd3eb --- /dev/null +++ b/packages/ecs/src/interface/system/up.type-test.ts @@ -0,0 +1,80 @@ +import { Component } from '../component/component.js'; +import { Entity } from '../entity/entity.js'; +import { System } from './system.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const local = Symbol("upTypeTest/local"); +const world = Symbol("upTypeTest/world"); +const parent = Symbol("upTypeTest/parent"); +const name = Symbol("upTypeTest/name"); + +declare module '@adobe/ecs' { + export interface Components { + [local]: { type: number }; + [world]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [name]: { type: string }; + } +} +Component.register(local, { type: { type: "number" } }); +Component.register(world, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(name, { type: { type: "string" } }); + +// `up` reads what `read` lists of the entity `through` names, or null for a row without one; it may read what the +// system writes. +System.create({ + name: "upTypeTest/propagate", + kernel: "cpu", + components: { read: [local], write: [world] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + check>(); + row[world] = up === null ? row[local] : up[world] + row[local]; + // @ts-expect-error up's values are read-only + if (up !== null) up[world] = 0; +}); + +// Without `up`, the context has none. +System.create({ name: "upTypeTest/none", components: { read: [local] } }, (row, context) => { + void row[local]; + // @ts-expect-error no up declared + void context.up; +}); + +System.create({ + name: "upTypeTest/unreferenced", + components: { write: [world] }, + // @ts-expect-error through is a reference component + up: { through: local, read: [world] }, +}, () => {}); + +System.create({ + name: "upTypeTest/text", + kernel: "cpu", + components: { write: [world] }, + // @ts-expect-error a kernel's up reads linear values + up: { through: parent, read: [name] }, +}, () => {}); + +// A GPU kernel reads up too. +System.create({ + name: "upTypeTest/gpu", + kernel: "gpu", + components: { read: [local], write: [world] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + row[world] = up === null ? row[local] : up[world] + row[local]; +}); + +System.create({ + name: "upTypeTest/rowless", + // @ts-expect-error up orders rows, so it needs components + up: { through: parent, read: [world] }, +}, () => {}); + +// An observer system reads what a sync point left, in no order. +// @ts-expect-error an observer system has no up +System.create({ observer: true, components: { read: [local] }, up: { through: parent, read: [local] } }, () => {}); diff --git a/packages/ecs/src/interface/system/values.ts b/packages/ecs/src/interface/system/values.ts new file mode 100644 index 000000000..1fbcfe76a --- /dev/null +++ b/packages/ecs/src/interface/system/values.ts @@ -0,0 +1,70 @@ +import type { Component } from '../component/component.js'; +import type { Entity } from '../entity/entity.js'; +import type { Index } from '../indexes/index-type.js'; +import type { Resource } from '../resource/resource.js'; +import type { Comparison } from '../world/filter.js'; +import type { Listed, Property } from './property.js'; + +export type ComponentValues = { readonly [K in Component.Key]: Component.Value }; +export type ResourceValues = { readonly [K in Resource.Key]: Resource.Value }; + +/** `Read` values are read-only, `Write` values writable. */ +export type Values = + { readonly [K in Exclude & Keys]: Types[K & keyof Types] } + & { -readonly [K in Write & Keys]: Types[K & keyof Types] }; + +/** An index declaration's key. */ +export type KeyOf = D extends { readonly key: infer K extends readonly Component.Key[] } ? K : readonly Component.Key[]; + +/** The components every entity an index holds has: its key parts and its `has`. */ +export type HeldBy = KeyOf[number] | Listed, "has">; + +/** The key's values, in key order. */ +type KeyValues = { readonly [I in keyof K]: Component.Value }; + +/** A prefix of the key, possibly empty, whose last part may be a comparison. */ +type Prefix = V extends readonly [infer First, ...infer Rest] + ? readonly [] | readonly [First | Comparison] | readonly [First, ...Prefix] + : readonly []; + +/** A row an index lookup gives: its entity and the lookup's `read` values, read-only. */ +type IndexRow = { readonly entity: Entity } & { readonly [K in Listed & Component.Key]: Component.Value }; + +/** + * The rows a lookup found, in the index's order: by entity for a hash index, by key and then entity for a sorted one. + * Valid until its call site runs again, and each row until the list is. + */ +type Rows = { + readonly length: number; + /** + * Whether, since its system last ran, an entity entered or left it or moved within it, or a component in the + * lookup's `changed` may have changed in one of its rows: conservative, as `changed` filters are, and true on a + * system's first run and in finalizers. + */ + readonly changed: boolean; + /** The row at `index`, from 0 to `length - 1`. */ + at(index: number): R; +}; + +/** A lookup through an index, by its declaration `D`: by key, a prefix of it for a sorted index. */ +type IndexLookup = + D extends { readonly unique: true } + ? D extends { readonly sorted: true } + ? ((...key: KeyValues>) => IndexRow | null) & ((...key: Prefix>>) => Rows>) + : (...key: KeyValues>) => IndexRow | null + : D extends { readonly sorted: true } + ? (...key: Prefix>>) => Rows> + : (...key: KeyValues>) => Rows>; + +/** + * The row a lookup by entity gives: the lookup's `read` values, read-only, and with `changed` declared, whether a + * component it names may have changed in the entity since its system last ran: conservative, as `changed` filters are, + * and true on a system's first run and in finalizers. With `changed`, valid until the lookup is called again. + */ +type EntityRow = { + readonly [K in (Listed & Component.Key) | (L extends { readonly changed: readonly Component.Key[] } ? "changed" : never)]: K extends Component.Key ? Component.Value : boolean +}; + +export type LookupFunction = L extends { readonly index: Index } + ? IndexLookup + : (entity: Entity) => EntityRow | null; diff --git a/packages/ecs/src/interface/thread/of.ts b/packages/ecs/src/interface/thread/of.ts new file mode 100644 index 000000000..073e618df --- /dev/null +++ b/packages/ecs/src/interface/thread/of.ts @@ -0,0 +1,8 @@ +import type { IsShared } from '../schema/is-struct.js'; +import type { Thread } from './thread.js'; + +/** + * The thread a value of type `T` declared with traits `Entry` lives on: none for a struct or a string, else its `thread` + * or `"world"`. + */ +export type ThreadOf = IsShared extends true ? never : Entry extends { readonly thread: infer H extends Thread } ? H : "world"; diff --git a/packages/ecs/src/interface/thread/thread.ts b/packages/ecs/src/interface/thread/thread.ts new file mode 100644 index 000000000..f495a25e6 --- /dev/null +++ b/packages/ecs/src/interface/thread/thread.ts @@ -0,0 +1,13 @@ +/** + * The logical threads, by augmentation: `interface Threads { audio: true }`. A logical thread is where object values + * live and where the code touching them runs. A worker can fill several; one that no thread claims is filled by `world`. + */ +export interface Threads { + /** The thread that created the world. */ + world: true; + /** The window's thread, where the DOM is. */ + main: true; +} + +/** A logical thread's name. */ +export type Thread = keyof Threads & string; diff --git a/packages/ecs/src/interface/thread/thread.type-test.ts b/packages/ecs/src/interface/thread/thread.type-test.ts new file mode 100644 index 000000000..07fbd4be5 --- /dev/null +++ b/packages/ecs/src/interface/thread/thread.type-test.ts @@ -0,0 +1,140 @@ +import { Component } from '../component/component.js'; +import type { IsNode, IsTransferable } from '../platform/platform.js'; +import { Resource } from '../resource/resource.js'; +import type { ThreadOf } from './of.js'; +import type { Thread } from './thread.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +// Threads are augmentable; `world` and `main` are built in. +declare module '@adobe/ecs' { + export interface Threads { + threadTypeTest: true; + } +} +check, "world" | "main" | "threadTypeTest">>(); +// @ts-expect-error a thread is a declared one +const undeclared: Thread = "elsewhere"; +void undeclared; + +const label = Symbol("threadTypeTest/label"); +const path = Symbol("threadTypeTest/path"); +const handle = Symbol("threadTypeTest/handle"); +const placedLabel = Symbol("threadTypeTest/placedLabel"); +const unknownThread = Symbol("threadTypeTest/unknownThread"); +const position = Symbol("threadTypeTest/position"); +const placedPosition = Symbol("threadTypeTest/placedPosition"); +const placedTag = Symbol("threadTypeTest/placedTag"); +const element = Symbol("threadTypeTest/element"); +const unplacedElement = Symbol("threadTypeTest/unplacedElement"); +const workerElement = Symbol("threadTypeTest/workerElement"); +const maybeElement = Symbol("threadTypeTest/maybeElement"); +const bitmap = Symbol("threadTypeTest/bitmap"); +const buffer = Symbol("threadTypeTest/buffer"); +const notTransferable = Symbol("threadTypeTest/notTransferable"); +const canvas = Symbol("threadTypeTest/canvas"); +const cache = Symbol("threadTypeTest/cache"); +const count = Symbol("threadTypeTest/count"); +const port = Symbol("threadTypeTest/port"); +const text = Symbol("threadTypeTest/text"); +const names = Symbol("threadTypeTest/names"); +const mode = Symbol("threadTypeTest/mode"); +const caption = Symbol("threadTypeTest/caption"); + +declare module '@adobe/ecs' { + export interface Components { + // Objects: on "world" without `thread`, or on the thread they name. + [path]: { type: readonly number[]; thread: "threadTypeTest" }; + [handle]: { type: Map; thread: "world" }; + [unknownThread]: { type: readonly number[]; thread: "elsewhere" }; + // Strings and structs live in shared memory, so they have no thread. + [label]: { type: string }; + [placedLabel]: { type: string; thread: "main" }; + [position]: { type: readonly [number, number, number] }; + [placedPosition]: { type: readonly [number, number, number]; thread: "main" }; + [placedTag]: { type: true; thread: "world" }; + // A DOM Node lives on "main", and says so. + [element]: { type: HTMLElement; thread: "main" }; + [unplacedElement]: { type: HTMLElement }; + [workerElement]: { type: HTMLElement; thread: "threadTypeTest" }; + [maybeElement]: { type: HTMLCanvasElement | null; thread: "main" }; + // Transferable values may be moved, never copied, to their thread. + [bitmap]: { type: ImageBitmap; thread: "threadTypeTest"; transfer: true }; + [buffer]: { type: ArrayBuffer; transfer: true }; + [notTransferable]: { type: Map; transfer: true }; + } + export interface Resources { + [canvas]: { type: OffscreenCanvas | null; thread: "threadTypeTest"; transfer: true }; + [cache]: { type: Map | null; thread: "main" }; + [count]: { type: number; thread: "main" }; + [port]: { type: MessagePort | null; transfer: true }; + [text]: { type: string; transfer: true }; + [names]: { type: Map; thread: "main" }; + [mode]: { type: "edit" | "view"; thread: "main" }; + [caption]: { type: string | null; thread: "main" }; + } +} + +Component.register(label, { type: { type: "string" } }); +Component.register(path, { type: { type: "array", items: { type: "number" } }, thread: "threadTypeTest" }); +Component.register(handle, { thread: "world" }); +Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 } }); +Component.register(element, { thread: "main" }); +Component.register(maybeElement, { thread: "main" }); +Component.register(bitmap, { thread: "threadTypeTest", transfer: true }); +Component.register(buffer, { transfer: true }); +Resource.register(canvas, { thread: "threadTypeTest", transfer: true }); +Resource.register(cache, { thread: "main" }); +Resource.register(port, { transfer: true }); + +// @ts-expect-error a thread is a declared one +Component.register(unknownThread, { type: { type: "array", items: { type: "number" } }, thread: "elsewhere" }); +// @ts-expect-error a string lives in shared memory, so it has no thread +Component.register(placedLabel, { type: { type: "string" }, thread: "main" }); +// @ts-expect-error a union of string literals is a string too +Resource.register(mode, { type: { anyOf: [{ const: "edit" }, { const: "view" }], default: "view" }, thread: "main" }); +// @ts-expect-error and so is a string or null +Resource.register(caption, { type: { anyOf: [{ type: "string" }, { type: "null" }], default: null }, thread: "main" }); +// @ts-expect-error a struct lives in shared memory, so it has no thread +Component.register(placedPosition, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, thread: "main" }); +// @ts-expect-error a tag is a struct too +Component.register(placedTag, { type: { const: true }, thread: "world" }); +// @ts-expect-error a struct resource has no thread either +Resource.register(count, { type: { type: "number", default: 0 }, thread: "main" }); +// @ts-expect-error a DOM Node declares thread: "main" +Component.register(unplacedElement, {}); +// @ts-expect-error a DOM Node lives on "main", not another thread +Component.register(workerElement, { thread: "threadTypeTest" }); +// @ts-expect-error only a Transferable value is moved +Component.register(notTransferable, { transfer: true }); +// @ts-expect-error a string is data, copied, never moved +Resource.register(text, { type: { type: "string", default: "" }, transfer: true }); +// @ts-expect-error a value that's data is registered with its schema +Component.register(label, {}); +// @ts-expect-error a component's object has no default: its value comes from commands +Component.register(handle, { default: new Map(), thread: "world" }); +// @ts-expect-error a resource without null starts at a default +Resource.register(names, { thread: "main" }); +// @ts-expect-error a default is a value of the type +Resource.register(names, { default: "map", thread: "main" }); +Resource.register(names, { default: new Map(), thread: "main" }); + +check, readonly number[]>>(); +check, never>>(); +check, never>>(); +check, "main">>(); +check, never>>(); +check, "world">>(); + +// The checks resolve to no check where the DOM library isn't loaded: a scope without `Node` has no DOM Node type. +type NoDom = { readonly ArrayBuffer: { readonly prototype: ArrayBuffer } }; +check, true>>(); +check, true>>(); +check, false>>(); +check, false>>(); +check, true>>(); +check, false>>(); +check, true>>(); +check, false>>(); +check, false>>(); diff --git a/packages/ecs/src/interface/trait/index.ts b/packages/ecs/src/interface/trait/index.ts new file mode 100644 index 000000000..01bc72c1a --- /dev/null +++ b/packages/ecs/src/interface/trait/index.ts @@ -0,0 +1 @@ +export { register } from './register.js'; diff --git a/packages/ecs/src/interface/trait/key.ts b/packages/ecs/src/interface/trait/key.ts new file mode 100644 index 000000000..08034b882 --- /dev/null +++ b/packages/ecs/src/interface/trait/key.ts @@ -0,0 +1,6 @@ +import type { Traits } from './types.js'; + +export type Key = keyof Traits; + +/** The core's traits, the only ones named by strings. */ +export type BuiltIn = "type" | "derived" | "within" | "reference" | "reduce" | "thread" | "transfer"; diff --git a/packages/ecs/src/interface/trait/register.ts b/packages/ecs/src/interface/trait/register.ts new file mode 100644 index 000000000..2e96f1f02 --- /dev/null +++ b/packages/ecs/src/interface/trait/register.ts @@ -0,0 +1,9 @@ +import { addTrait } from '../registry/traits.js'; +import type { BuiltIn, Key } from './key.js'; + +/** + * Registers a trait under `key`, a plain `Symbol(...)`: string trait names are the core's built-in traits. The same + * trait again is accepted, and so is a new symbol with a trait's id, as a module evaluated again makes it. Throws for a key that isn't a plain symbol, or + * one registered as a component or resource. + */ +export const register = (key: Exclude & symbol): void => addTrait(key); diff --git a/packages/ecs/src/interface/trait/required.ts b/packages/ecs/src/interface/trait/required.ts new file mode 100644 index 000000000..aad9c6195 --- /dev/null +++ b/packages/ecs/src/interface/trait/required.ts @@ -0,0 +1,7 @@ +import type { IsNode } from '../platform/platform.js'; + +/** + * The traits a declaration must have beyond those it lists: a DOM `Node` lives on `"main"`, so a declaration of one + * without `thread` can't be registered. Where the DOM library isn't loaded, nothing is required. + */ +export type Required = IsNode extends true ? (Entry extends { readonly thread: unknown } ? {} : { readonly thread: "main" }) : {}; diff --git a/packages/ecs/src/interface/trait/symbol-keys.ts b/packages/ecs/src/interface/trait/symbol-keys.ts new file mode 100644 index 000000000..b19e79e3a --- /dev/null +++ b/packages/ecs/src/interface/trait/symbol-keys.ts @@ -0,0 +1,19 @@ +import type { Components } from '../component/types.js'; +import type { Resources } from '../resource/types.js'; +import type { BuiltIn } from './key.js'; +import type { Traits } from './types.js'; + +/** A required property naming `problem` and the keys that cause it, or nothing without such keys. */ +type Problem = [Keys] extends [never] ? {} : { readonly [P in Text]: Keys }; + +/** + * What every declaration also requires while `C`, `R` or `T` has a key that isn't a symbol, besides the built-in + * traits: a property named for the problem, holding the keys, so registration fails to compile and says why. + */ +export type KeysMustBeSymbols = + Problem, "component keys must be symbols"> + & Problem, "resource keys must be symbols"> + & Problem, "trait keys must be symbols">; + +/** `KeysMustBeSymbols` of the program's components, resources and traits, computed once. */ +export type SymbolKeys = KeysMustBeSymbols>; diff --git a/packages/ecs/src/interface/trait/symbol-keys.type-test.ts b/packages/ecs/src/interface/trait/symbol-keys.type-test.ts new file mode 100644 index 000000000..439cbc60a --- /dev/null +++ b/packages/ecs/src/interface/trait/symbol-keys.type-test.ts @@ -0,0 +1,14 @@ +import type { KeysMustBeSymbols } from './symbol-keys.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +declare const key: unique symbol; +type BuiltInTraits = { readonly type: unknown; readonly derived: true; readonly within: never; readonly reference: never; readonly reduce: never; readonly thread: never; readonly transfer: never }; + +// Symbol keys, and the built-in string traits, need nothing. +check, {}>>(); +// Each kind with a string or number key requires a property naming the problem and the keys. +check, { readonly "component keys must be symbols": "position" }>>(); +check, { readonly "resource keys must be symbols": 1 }>>(); +check, { readonly "trait keys must be symbols": "gpuOnly" }>>(); diff --git a/packages/ecs/src/interface/trait/trait.ts b/packages/ecs/src/interface/trait/trait.ts new file mode 100644 index 000000000..f8d7cd9cc --- /dev/null +++ b/packages/ecs/src/interface/trait/trait.ts @@ -0,0 +1,3 @@ +export type Trait = never; + +export * as Trait from './index.js'; diff --git a/packages/ecs/src/interface/trait/trait.type-test.ts b/packages/ecs/src/interface/trait/trait.type-test.ts new file mode 100644 index 000000000..f271c17db --- /dev/null +++ b/packages/ecs/src/interface/trait/trait.type-test.ts @@ -0,0 +1,158 @@ +import { Component } from '../component/component.js'; +import { Entity } from '../entity/entity.js'; +import { Resource } from '../resource/resource.js'; +import type { Commands } from '../system/commands.js'; +import type { IsStruct } from '../schema/is-struct.js'; +import type { Components } from '../component/types.js'; +import { Trait } from './trait.js'; +import type { Derived, Essential } from '../component/value.js'; +import type { Reduced } from '../resource/reduction.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +// Traits other packages define: one only linear components take, one derived components don't need. +const gpuOnly = Symbol("traitTest/gpuOnly"); +const nonPersistent = Symbol("traitTest/nonPersistent"); +declare module '@adobe/ecs' { + export interface Traits { + readonly [gpuOnly]: N extends Component.Key ? (IsStruct> extends true ? true : never) : never; + readonly [nonPersistent]: N extends Component.Key ? (Components[N] extends { readonly derived: true } ? never : true) : true; + } +} +Trait.register(gpuOnly); +Trait.register(nonPersistent); +// @ts-expect-error string trait names are the core's built-in traits +Trait.register("derived"); + +const parent = Symbol("traitTest/parent"); +const follows = Symbol("traitTest/follows"); +const slot = Symbol("traitTest/slot"); +const ready = Symbol("traitTest/ready"); +const score = Symbol("traitTest/score"); +const link = Symbol("traitTest/link"); +const pair = Symbol("traitTest/pair"); +const cache = Symbol("traitTest/cache"); +const badReference = Symbol("traitTest/badReference"); +const selfWithin = Symbol("traitTest/selfWithin"); +const derivedWithin = Symbol("traitTest/derivedWithin"); +const derivedReference = Symbol("traitTest/derivedReference"); +const gpuString = Symbol("traitTest/gpuString"); +const derivedNonPersistent = Symbol("traitTest/derivedNonPersistent"); +const time = Symbol("traitTest/time"); +const derivedResource = Symbol("traitTest/derivedResource"); +const sum = Symbol("traitTest/sum"); +const anyFlag = Symbol("traitTest/anyFlag"); +const named = Symbol("traitTest/named"); +const reducedComponent = Symbol("traitTest/reducedComponent"); + +declare module '@adobe/ecs' { + export interface Components { + [parent]: { type: Entity; reference: "owner" }; + [follows]: { type: Entity; reference: "weak" }; + [slot]: { type: number; derived: true }; + [ready]: { type: true; within: typeof slot }; + [score]: { type: number }; + [link]: { type: { readonly to: Entity; readonly weight: number } }; + [pair]: { type: readonly [number, number]; [gpuOnly]: true }; + [cache]: { type: number; [nonPersistent]: true }; + [badReference]: { type: number; reference: "owner" }; + [selfWithin]: { type: true; within: typeof selfWithin }; + [derivedWithin]: { type: number; derived: true; within: typeof score }; + [derivedReference]: { type: Entity; derived: true; reference: "owner" }; + [gpuString]: { type: string; [gpuOnly]: true }; + [derivedNonPersistent]: { type: number; derived: true; [nonPersistent]: true }; + [reducedComponent]: { type: number; reduce: "+" }; + } + export interface Resources { + [time]: { type: number }; + [derivedResource]: { type: number; derived: true }; + [sum]: { type: number; reduce: "+" }; + [anyFlag]: { type: boolean; reduce: "+" }; + [named]: { type: string; reduce: "max" }; + } +} + +// Registration takes exactly the declared traits, `type` as a schema for the declared type. +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(follows, { type: Entity.schema, reference: "weak" }); +Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +Component.register(ready, { type: { const: true }, within: slot }); +Component.register(score, { type: { type: "number" } }); +Component.register(link, { type: { type: "object", properties: { to: Entity.schema, weight: { type: "number" } } } }); +Component.register(pair, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, [gpuOnly]: true }); +Component.register(cache, { type: { type: "number" }, [nonPersistent]: true }); +Resource.register(time, { type: { type: "number", default: 0 } }); +Resource.register(sum, { type: { type: "number", default: 0 }, reduce: "+" }); + +// @ts-expect-error a derived component's type has a default, which it starts at +Component.register(slot, { type: { type: "number" }, derived: true }); +// @ts-expect-error a reference's value is an entity +Component.register(badReference, { type: { type: "number" }, reference: "owner" }); +// @ts-expect-error a component can't live within itself +Component.register(selfWithin, { type: { const: true }, within: selfWithin }); +// @ts-expect-error a derived component's rule covers within +Component.register(derivedWithin, { type: { type: "number", default: 0 }, derived: true, within: score }); +// @ts-expect-error a rule can't set a derived component's target +Component.register(derivedReference, { type: { ...Entity.schema, default: Entity.none }, derived: true, reference: "owner" }); +// @ts-expect-error gpuOnly takes only linear components +Component.register(gpuString, { type: { type: "string" }, [gpuOnly]: true }); +// @ts-expect-error nonPersistent is redundant on a derived component +Component.register(derivedNonPersistent, { type: { type: "number", default: 0 }, derived: true, [nonPersistent]: true }); +// @ts-expect-error a declared trait is missing +Component.register(parent, { type: Entity.schema }); +// @ts-expect-error an undeclared trait is given +Component.register(score, { type: { type: "number" }, derived: true }); +// @ts-expect-error a trait's value differs from the declared one +Component.register(follows, { type: Entity.schema, reference: "owner" }); +// @ts-expect-error an unknown trait +Component.register(score, { type: { type: "number" }, unknown: true }); +// @ts-expect-error an entity's schema is Entity.schema +Component.register(parent, { type: { type: "integer" }, reference: "owner" }); +// @ts-expect-error a number's schema isn't Entity.schema +Component.register(score, { type: Entity.schema }); +// @ts-expect-error an entity field's schema is Entity.schema too +Component.register(link, { type: { type: "object", properties: { to: { type: "integer" }, weight: { type: "number" } } } }); +// @ts-expect-error a resource's schema has a default +Resource.register(time, { type: { type: "number" } }); +// @ts-expect-error derived is a component trait +Resource.register(derivedResource, { type: { type: "number", default: 0 }, derived: true }); + +// Types filter components by their traits and value types. +type Ours = typeof parent | typeof follows | typeof slot | typeof ready | typeof score | typeof link | typeof pair | typeof cache; +check, Entity>>(); +check, { readonly to: Entity; readonly weight: number }>>(); +check, Ours>, typeof parent | typeof follows>>(); +check, Ours>, typeof parent>>(); +check, typeof slot>>(); +check, Ours>, typeof pair>>(); +type Persisted = Exclude>; +check, Exclude>>(); +check, number>>(); + +// Commands add and remove only essential components. +export const issue = (commands: Commands, entity: Entity): void => { + commands.add(entity, [score, parent], { [score]: 1, [parent]: entity }); + commands.remove(entity, score); + // @ts-expect-error derived components are added only by rules + commands.spawn([slot])({ [slot]: 1 }); + // @ts-expect-error derived components are added only by rules + commands.add(entity, slot, 1); + // @ts-expect-error derived components are removed only by rules + commands.remove(entity, slot); +}; + +// Entities are branded numbers. +const raw: number = Entity.none; +// @ts-expect-error a number isn't an entity +const forged: Entity = 3; +void raw; +void forged; +// @ts-expect-error booleans reduce bitwise +Resource.register(anyFlag, { type: { type: "boolean", default: false }, reduce: "+" }); +// @ts-expect-error only numbers and booleans, and tuples and structs of them, are reduced +Resource.register(named, { type: { type: "string", default: "" }, reduce: "max" }); +// @ts-expect-error reduce is a resource trait +Component.register(reducedComponent, { type: { type: "number" }, reduce: "+" }); +check>(); +check>(); diff --git a/packages/ecs/src/interface/trait/types.ts b/packages/ecs/src/interface/trait/types.ts new file mode 100644 index 000000000..bc2887e71 --- /dev/null +++ b/packages/ecs/src/interface/trait/types.ts @@ -0,0 +1,56 @@ +import type { Component } from '../component/component.js'; +import type { Components } from '../component/types.js'; +import type { Entity } from '../entity/entity.js'; +import type { IsNode, IsTransferable } from '../platform/platform.js'; +import type { Resource } from '../resource/resource.js'; +import type { Reduction } from '../resource/reduction.js'; +import type { IsString, IsStruct } from '../schema/is-struct.js'; +import type { Thread } from '../thread/thread.js'; + +/** The value type of the component or resource `N`. */ +type ValueOf = N extends Component.Key ? Component.Value : N extends Resource.Key ? Resource.Value : never; + +/** + * Whether each component's and resource's value is a struct, `"string"` for a string, computed once so a check is a + * lookup. Its keys are remapped (`as K`) so a generic key's lookup stays one: resolved as `IsStruct` of a generic value, + * as a registration resolves its contextual type, it recurses without end. + */ +type Structs = { readonly [K in Component.Key | Resource.Key as K]: IsString> extends true ? "string" : IsStruct> }; + +/** + * The value each trait allows on the component or resource `N`. Augment it, with this exact type parameter, to + * define a trait, and register its name with `Trait.register`. A trait that doesn't apply to `N` resolves to `never`. + * An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over + * every component, or checking grows with the square of the component count. + */ +export interface Traits { + /** The value type: declared as a TypeScript type, registered as a schema for it when it's data. */ + readonly type: unknown; + /** Present only while a rule deriving it matches, so commands never add or remove it. */ + readonly derived: N extends Component.Key ? true : never; + /** Exists only while its host component does, and is removed with it. Not on a derived component, which its rule covers. */ + readonly within: N extends Component.Key + ? Components[N] extends { readonly derived: true } | { readonly within: N } ? never : Component.Key + : never; + /** + * On an entity-valued component: when its target despawns, `owner` despawns the holder and `weak` removes the + * component. Not on a derived component, whose target only a command could set. + */ + readonly reference: N extends Component.Key + ? Components[N] extends { readonly derived: true } ? never : [Component.Value] extends [Entity] ? "owner" | "weak" : never + : never; + /** + * On a resource, the operator systems reduce into it with: each run that a contributor runs in starts it at the + * operator's identity and leaves it holding the fold of every contribution. Booleans fold bitwise. + */ + readonly reduce: N extends Resource.Key + ? [Resource.Value] extends [boolean] ? "&" | "|" | "^" : Structs[N] extends true ? Reduction : never + : never; + /** + * The logical thread an object lives on, `"world"` without it. Not on a struct or a string, which live in shared + * memory. A DOM `Node` lives on `"main"`, and says so. + */ + readonly thread: Structs[N] extends true | "string" ? never : IsNode> extends true ? "main" : Thread; + /** The value may be moved, never copied, to its thread: only a Transferable value. */ + readonly transfer: IsTransferable> extends true ? true : never; +} diff --git a/packages/ecs/src/interface/tsconfig.json b/packages/ecs/src/interface/tsconfig.json new file mode 100644 index 000000000..7fbfaac50 --- /dev/null +++ b/packages/ecs/src/interface/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../data/tsconfig-base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "../../dist/interface", + "composite": true, + "types": [ + "@webgpu/types" + ], + "paths": { + "@adobe/ecs": [ + "./index.ts" + ], + "@adobe/ecs/*": [ + "./index.ts" + ] + }, + "lib": [ + "ES2024", + "ESNext.Disposable", + "DOM", + "DOM.Iterable" + ] + }, + "include": [ + "**/*" + ] +} diff --git a/packages/ecs/src/interface/world/create.ts b/packages/ecs/src/interface/world/create.ts new file mode 100644 index 000000000..1d57ee692 --- /dev/null +++ b/packages/ecs/src/interface/world/create.ts @@ -0,0 +1,15 @@ +import type { Resource } from '../resource/resource.js'; +import type { WorldWritten } from '../resource/written.js'; +import type { World } from './world.js'; + +/** Values resources start at in place of their defaults: any but those only the world writes. */ +export type InitialResources = { readonly [K in Exclude]?: Resource.Value }; + +/** + * Creates a world on this thread, synchronously, with resources starting at the given values in place of their + * defaults. This thread is its `"world"` thread for the world's life: it runs the sync points, the ticks and the GPU + * device, and fills every logical thread no joined thread claims. Keys are the resources' symbols, built-in ones + * included (`{ [workers]: 0 }`); string keys are never a resource's, so they're ignored. Throws for a symbol that isn't + * a resource, or is one only the world writes. + */ +export type Create = (resources?: InitialResources) => World; diff --git a/packages/ecs/src/interface/world/filter.ts b/packages/ecs/src/interface/world/filter.ts new file mode 100644 index 000000000..273732712 --- /dev/null +++ b/packages/ecs/src/interface/world/filter.ts @@ -0,0 +1,9 @@ + +export type Comparison = { + readonly "=="?: T; + readonly "!="?: T; + readonly "<"?: T; + readonly ">"?: T; + readonly "<="?: T; + readonly ">="?: T; +}; diff --git a/packages/ecs/src/interface/world/index.ts b/packages/ecs/src/interface/world/index.ts new file mode 100644 index 000000000..e357bae62 --- /dev/null +++ b/packages/ecs/src/interface/world/index.ts @@ -0,0 +1,2 @@ +// The `World` namespace's members: none, since a consumer declares nothing more with it. +export {}; diff --git a/packages/ecs/src/interface/world/join.ts b/packages/ecs/src/interface/world/join.ts new file mode 100644 index 000000000..99b5616f6 --- /dev/null +++ b/packages/ecs/src/interface/world/join.ts @@ -0,0 +1,10 @@ +import type { Thread } from '../thread/thread.js'; +import type { World } from './world.js'; + +/** + * Joins, from another thread, a world that `share` let join through the other end of `port`, claiming the logical + * `threads` this thread fills: their objects live here, and the systems and finalizers reading them run here. The world + * it returns is the same one: its `run` runs systems there, and its `add` and `remove` take observer systems, which run + * here after the world's sync points. Its `dispose` disconnects this thread. + */ +export type Join = (port: Parameters[0], options?: { readonly threads?: readonly Exclude[] }) => World; diff --git a/packages/ecs/src/interface/world/loop.type-test.ts b/packages/ecs/src/interface/world/loop.type-test.ts new file mode 100644 index 000000000..c5bce62d5 --- /dev/null +++ b/packages/ecs/src/interface/world/loop.type-test.ts @@ -0,0 +1,67 @@ +import { + Finalizer, System, always, cadence, clock, gpuDevice, wake, workers, + type Component, type Resource, type World, +} from '../index.js'; +import type { Create } from './create.js'; +import type * as Ecs from '../index.js'; +import { main, move } from '../system/system.type-test.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +declare const world: World; +declare const create: Create; + +// Systems added to every tick take no input. +const added: void = world.add(main); +// @ts-expect-error a system with input can't be added, since ticks pass none +world.add(move); +void added; +// Removing takes a value passed to add, which takes no input. +const removed: void = world.remove(main); +// @ts-expect-error a system with input was never added +world.remove(move); +void removed; + +// Built-in resources. +check, GPUDevice | null>>(); +check, number | null>>(); +// null is the platform's choice of pool size. +export const platformPool: World = create({ [workers]: null }); +check, boolean>>(); +check, number>>(); +check, { readonly now: number; readonly delta: number; readonly tick: number }>>(); +check, { readonly kind: "frame" } | { readonly kind: "fixed"; readonly step: number; readonly maxSteps?: number }>>(); +// Built-in resources are symbols, so worlds start them at other values through their imported keys. +check>(); +// @ts-expect-error a resource key is its imported symbol, never its description +create({ workers: 2 }); + +// There are no built-in components: a joined thread's progress is the implementation's. +type Removed = "component" | "persistent" | "shared" | "threadPort" | "threadStatus" | "threadClaims"; +check, never>>(); + +// Systems read the loop's state, ask for a wake time and change its cadence. +System.create({ name: "loopTypeTest/read", resources: { read: [clock] } }, ({ resources }) => { + const now: number = resources[clock].now; + void now; +}); +System.create({ name: "loopTypeTest/pace", resources: { write: [cadence, always] } }, ({ resources }) => { + resources[cadence] = { kind: "fixed", step: 1000 / 60, maxSteps: 2 }; + resources[always] = true; +}); +System.create({ name: "loopTypeTest/sleep", reduce: { [wake]: "min" } }, ({ reduce }) => { reduce[wake](1000); }); +// @ts-expect-error clock is written only by the world +System.create({ name: "loopTypeTest/clock", resources: { write: [clock] } }, () => {}); +// @ts-expect-error wake is reduced, never written +System.create({ name: "loopTypeTest/wake", resources: { write: [wake] } }, () => {}); +// @ts-expect-error a finalizer doesn't write clock either +Finalizer.create({ name: "loopTypeTest/finalizeClock", components: {}, resources: { write: [clock] } }, () => {}); +// @ts-expect-error a cadence is a frame or a fixed step +System.create({ name: "loopTypeTest/badCadence", resources: { write: [cadence] } }, ({ resources }) => { resources[cadence] = { kind: "fixed" }; }); + +// Worlds start the loop's settings at other values; clock is the world's own. +const paced: World = create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true, [workers]: 2 }); +// @ts-expect-error clock is written only by the world +create({ [clock]: { now: 0, delta: 0, tick: 0 } }); +void paced; diff --git a/packages/ecs/src/interface/world/share.type-test.ts b/packages/ecs/src/interface/world/share.type-test.ts new file mode 100644 index 000000000..f4c86f0a7 --- /dev/null +++ b/packages/ecs/src/interface/world/share.type-test.ts @@ -0,0 +1,25 @@ +import type { World } from './world.js'; + +declare const world: World; +declare const channel: MessageChannel; +declare const worker: Worker; + +// Sharing lets another thread join through a port: a MessagePort, a Worker or a worker's own scope. +const shared: void = world.share(channel.port1); +world.share(worker); +world.share(self); +// A port is share's parameter, inferred where a consumer names it. +const port: Parameters[0] = channel.port2; +// @ts-expect-error a port posts messages to another thread +world.share({}); +// @ts-expect-error a thread is shared through one port +world.share(); + +// The package root's World.join joins; World names no type for it, its options or its port. +// @ts-expect-error no World.Join +export type Joining = World.Join; +// @ts-expect-error no World.JoinOptions +export type Options = World.JoinOptions; +// @ts-expect-error no World.Port +export type Ported = World.Port; +void [shared, port]; diff --git a/packages/ecs/src/interface/world/world.ts b/packages/ecs/src/interface/world/world.ts new file mode 100644 index 000000000..67f9536c6 --- /dev/null +++ b/packages/ecs/src/interface/world/world.ts @@ -0,0 +1,58 @@ +import type { Input } from '../system/input.js'; +import type { System } from '../system/system.js'; + +/** + * A world. Systems read and change it; observer systems read it after each run's and tick's sync point. Every member's + * type is named from the package root, so declarations a consumer emits for its results name no internal module. + */ +export type World = { + /** + * The one way to change state. Runs are sequential in call order; physically, any that don't conflict overlap. Each + * runs a system or composite, ending in a sync point, and resolves to its number: every run and tick takes the + * next, in the order they run. What went wrong, if anything, is a report the world spawns for that number (see + * `Report`): systems, finalizers and GPU work that failed, systems skipped after a failed tier, commands that did + * nothing, and warnings. Rejects only when the run can't happen or finish: registration fails, the world is + * disposed, its GPU device is lost, or its sync point can't apply a command's value. Observer systems, the run's and + * those added, run after its sync point and its report, before it resolves; their throws are `"system"` failures + * in the next run's report, since each report's errors are written once. A run of observer systems alone runs them + * once after an empty sync point, and doesn't wake the loop. + */ + run(system: System, ...input: Input>): Promise; + /** + * Adds a system to every tick, from the next one on, ordered with those already added by its constraints, as a + * composite's members are. A tick runs on a beat of the `cadence` resource where `always` is set, `wake` is due or + * a run arrived since the last tick, and only while some system is added. Throws for a system with no ordering path + * to one already added that conflicts with it; failures are in the tick's report. An observer system runs after + * every run's and tick's sync point instead. It never conflicts and doesn't count as an added system for ticking. + * On a joined thread `add` takes only observer systems, whose keys need ids: each runs on that thread after the + * world's sync points, once for the newest when the thread fell behind, reading structs and the objects of the + * threads it fills as of one sync point; its failures are in a later run's report. + */ + add(system: System): void; + /** + * Takes a value passed to `add`. Once it returns, that system's body is never called again, and its memory is + * released. A system added both alone and within an added composite stays until both are removed; constraints + * naming a removed system are ignored. Removing a value that isn't added does nothing. Throws, removing nothing, + * when the systems left would conflict with no ordering path between them, as `add` does. On a joined thread it + * takes only observer systems. + */ + remove(system: System): void; + /** + * Lets another thread join this world through `port`, one end of a channel to it, of the browser or node: a + * `MessagePort`, a `Worker`, or a worker's own scope, `self` or node's `parentPort`. That thread joins through the + * other end, claiming the logical threads it fills; a thread claimed twice can't join. Throws on a joined thread, + * and where memory can't be shared, as without cross-origin isolation. + */ + share(port: { readonly postMessage: (message: never, transfer: never) => void } & ( + | { readonly addEventListener: (type: "message", listener: (event: unknown) => void) => void } + | { readonly on: (event: "message", listener: (value: unknown) => void) => unknown } + )): void; + /** + * Waits for pending runs, then stops ticking and releases memory, and joined threads' runs reject; the world is + * unusable afterwards. On a joined thread, it disconnects this thread. + */ + dispose(): Promise; + [Symbol.asyncDispose](): Promise; +}; + +export type * as World from './index.js'; diff --git a/packages/ecs/src/interface/world/world.type-test.ts b/packages/ecs/src/interface/world/world.type-test.ts new file mode 100644 index 000000000..3a5360542 --- /dev/null +++ b/packages/ecs/src/interface/world/world.type-test.ts @@ -0,0 +1,81 @@ +import { Component } from '../component/component.js'; +import type { Entity } from '../entity/entity.js'; +import { System } from '../system/system.js'; +import { main, move, position, time, total, watchTime } from '../system/system.type-test.js'; +import type { Create } from './create.js'; +import type { World } from './world.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +declare const world: World; +declare const entity: Entity; +declare const create: Create; + +const point = Symbol("worldTypeTest/point"); +const persistent = Symbol("worldTypeTest/persistent"); +const shared = Symbol("worldTypeTest/shared"); +declare module '@adobe/ecs' { + export interface Components { + [point]: { type: { readonly x: number } }; + [persistent]: { type: true }; + [shared]: { type: true }; + } +} +Component.register(point, { type: { type: "object", properties: { x: { type: "number" } } } }); +Component.register(persistent, { type: { const: true } }); +Component.register(shared, { type: { const: true } }); +// Reading goes through observer systems: a world has no observers, and no synchronous reads. +// @ts-expect-error no observe: observer systems read a world +void world.observe; +// @ts-expect-error no synchronous reads +void world.query; +// @ts-expect-error no synchronous reads +void world.read; +// @ts-expect-error GPU writes are read back for observer systems, without synchronize +void world.synchronize; +// @ts-expect-error no World.Query: observer systems declare what they read +export type NoQuery = World.Query<[typeof persistent]>; + +// A run resolves to its number; what went wrong is in its report (`Report`). +const ran: Promise = world.run(main); +const moved: Promise = world.run(move, { distance: 1 }); +// @ts-expect-error a system with input needs it +world.run(move); +// @ts-expect-error input must match its schema +world.run(move, { distance: "far" }); +// @ts-expect-error a system without input takes none +world.run(main, {}); + +const created: World = create(); +const withResources: World = create({ [time]: 1, [total]: 0 }); +// @ts-expect-error a resource's initial value has its type +create({ [time]: "now" }); +// @ts-expect-error resources takes only resource keys +create({ [position]: 0 }); +// @ts-expect-error a key is its symbol, never its description +create({ "systemTest/time": 1 }); +// @ts-expect-error systems register on their first run +void world.systems; +const disposed: Promise = world.dispose(); +const usingDispose = async () => { + await using scoped = create(); + await scoped.run(main); +}; +void [disposed, usingDispose, ran, moved, created, withResources]; + +// How a world is created is the implementation's: World names no Create or InitialResources. +check, World>>(); +// @ts-expect-error no World.Create +export type Created = World.Create; +// @ts-expect-error no World.InitialResources: Parameters[0] +export type Initial = World.InitialResources; +// @ts-expect-error no World.Failure: Report.Failure +export type Failed = World.Failure; +// @ts-expect-error no World.Warning: Report.Warning +export type Warned = World.Warning; + +// An observer system is added to every run and tick, or run once after an empty sync point. +const addedObserver: void = world.add(watchTime); +const ranObserver: Promise = world.run(watchTime); +void [addedObserver, ranObserver]; diff --git a/packages/ecs/src/observe-imports.test.ts b/packages/ecs/src/observe-imports.test.ts new file mode 100644 index 000000000..9f542914c --- /dev/null +++ b/packages/ecs/src/observe-imports.test.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const source = dirname(fileURLToPath(import.meta.url)); +const module = join(source, "observe"); + +/** Every TypeScript file under a directory. */ +const filesOf = (directory: string): string[] => readdirSync(directory, { withFileTypes: true }).flatMap(entry => + entry.isDirectory() ? filesOf(join(directory, entry.name)) : entry.name.endsWith(".ts") ? [join(directory, entry.name)] : []); + +/** The specifiers a file imports or re-exports, statically or dynamically, and the modules its declarations augment. */ +const specifiersOf = (file: string): string[] => { + const code = readFileSync(file, "utf8"); + return [ + ...code.matchAll(/(?:^|[\s;])(?:import|export)\s[^'"]*?\sfrom\s*["']([^"']+)["']/g), + ...code.matchAll(/(?:^|[\s;])import\s*["']([^"']+)["']/g), + ...code.matchAll(/import\s*\(\s*["']([^"']+)["']\s*\)/g), + ...code.matchAll(/declare\s+module\s+["']([^"']+)["']/g), + ].map(match => match[1]); +}; + +/** + * Whether `src/observe` may import a specifier from `file`: the interface's index, the public API but for how a world is + * made, which the factories never do, or a module of its own. + */ +const allowed = (file: string, specifier: string): boolean => { + if (specifier === "@adobe/ecs") return true; + if (!specifier.startsWith(".")) return false; + const target = resolve(dirname(file), specifier); + return target === join(source, "interface", "index.js") || !relative(module, target).startsWith(".."); +}; + +describe("src/observe, the observer factories", () => { + it("imports only the interface's index and its own modules", () => { + const files = filesOf(module); + expect(files.length).toBeGreaterThan(0); + const outside = files.flatMap(file => specifiersOf(file).filter(specifier => !allowed(file, specifier)).map(specifier => `${relative(source, file)}: ${specifier}`)); + expect(outside).toEqual([]); + }); + + it("finds imports from anywhere else", () => { + const file = join(module, "probe.ts"); + expect(["../index.js", "../implementation/index.js", "node:fs", "../interface/world/world.js", "../interface/../index.js"].filter(specifier => allowed(file, specifier))).toEqual([]); + expect(["../interface/index.js", "./observe.js", "@adobe/ecs"].every(specifier => allowed(file, specifier))).toBe(true); + }); +}); diff --git a/packages/ecs/src/observe/compare.ts b/packages/ecs/src/observe/compare.ts new file mode 100644 index 000000000..9f9ea0229 --- /dev/null +++ b/packages/ecs/src/observe/compare.ts @@ -0,0 +1,86 @@ +// Comparing and testing component values for queries: equality, order, and `where` conditions compiled once. + +/** Whether two values are equal: primitives by identity, objects and arrays by their own keys, recursively. */ +export const equals = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + const keys = Reflect.ownKeys(a); + if (keys.length !== Reflect.ownKeys(b).length) return false; + for (let i = 0; i < keys.length; i++) if (!equals(Reflect.get(a, keys[i]), Reflect.get(b, keys[i]))) return false; + return true; +}; + +/** Code points of two strings compared, never by locale, as sorted indexes order them. */ +const compareStrings = (a: string, b: string): number => { + const length = Math.min(a.length, b.length); + for (let k = 0; k < length; k++) { + const x = a.charCodeAt(k); + const y = b.charCodeAt(k); + if (x === y) continue; + // A surrogate encodes a code point above every unit that isn't one. + const xs = x >= 0xD800 && x <= 0xDFFF; + const ys = y >= 0xD800 && y <= 0xDFFF; + if (xs !== ys) return xs ? 1 : -1; + return x < y ? -1 : 1; + } + return a.length - b.length; +}; + +/** + * Orders values as sorted indexes do: numbers numerically with NaN after every number, booleans false first, strings + * by code point, tuples element by element; anything else is equal. + */ +export const compare = (a: unknown, b: unknown): number => { + if (typeof a === "number" && typeof b === "number") return compareNumbers(a, b); + if (Array.isArray(a) && Array.isArray(b)) { + const length = Math.min(a.length, b.length); + for (let i = 0; i < length; i++) { + const order = compare(a[i], b[i]); + if (order !== 0) return order; + } + return a.length - b.length; + } + if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1; + if (typeof a === "string" && typeof b === "string") return compareStrings(a, b); + return 0; +}; + +/** Numbers in order, NaN after every other, -0 equal to 0. */ +export const compareNumbers = (a: number, b: number): number => { + if (a < b) return -1; + if (a > b) return 1; + if (a === b) return 0; + const nanA = a !== a; + return nanA === (b !== b) ? 0 : nanA ? 1 : -1; +}; + +const operators = new Set(["==", "!=", "<", ">", "<=", ">="]); + +/** A `where` condition compiled once: a plain value is equality, an object of comparison operators is a comparison. */ +export const predicateOf = (condition: unknown): ((value: unknown) => boolean) => { + if (typeof condition !== "object" || condition === null || Array.isArray(condition)) { + if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") return value => value === condition; + return value => equals(value, condition); + } + const entries = Object.entries(condition); + if (entries.length === 0 || !entries.every(([operator]) => operators.has(operator))) return value => equals(value, condition); + const tests = entries.map(([operator, operand]): ((value: unknown) => boolean) => { + switch (operator) { + case "==": return value => equals(value, operand); + case "!=": return value => !equals(value, operand); + case "<": return typeof operand === "number" ? value => typeof value === "number" ? value < operand : compare(value, operand) < 0 : value => compare(value, operand) < 0; + case ">": return typeof operand === "number" ? value => typeof value === "number" ? value > operand : compare(value, operand) > 0 : value => compare(value, operand) > 0; + case "<=": return typeof operand === "number" ? value => typeof value === "number" ? value <= operand : compare(value, operand) <= 0 : value => compare(value, operand) <= 0; + default: return typeof operand === "number" ? value => typeof value === "number" ? value >= operand : compare(value, operand) >= 0 : value => compare(value, operand) >= 0; + } + }); + if (tests.length === 1) return tests[0]; + if (tests.length === 2) { + const [first, second] = tests; + return value => first(value) && second(value); + } + return value => { + for (let i = 0; i < tests.length; i++) if (!tests[i](value)) return false; + return true; + }; +}; diff --git a/packages/ecs/src/observe/entity.ts b/packages/ecs/src/observe/entity.ts new file mode 100644 index 000000000..c19eaf94f --- /dev/null +++ b/packages/ecs/src/observe/entity.ts @@ -0,0 +1,224 @@ +import { Index, System, type Component, type Entity, type World } from '../interface/index.js'; +import type { Observe } from './observe.js'; +import { createEntry, give, giveFirst, keysId, labelOf, none, noDelivery, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; + +/** An observed entity's value, shared by its observers, and by optional component whether it had it last. */ +type EntityEntry = Entry & { readonly entity: Entity; readonly present: boolean[] }; + +/** + * The entities observed with one list of required and optional components, read by one observer system that looks each + * up by entity; with no optional components, it skips them while an index of what has the required ones reports no + * change. `swept` is false while an entry may have no subscribers. + */ +type EntityShape = Shape & { + readonly system: System; + /** On a joined thread, a run of its system, once resolved giving first values its pass read. */ + readonly read: Read; + /** Whether its system is added: it's removed once the shape has been idle for a pass, and added again on use. */ + attached: boolean; + readonly entries: EntityEntry[]; + readonly byEntity: Map; + waiting: boolean; + swept: boolean; +}; + +/** By world, its entity observers' shapes, kept for its life, so their systems register in it once. */ +const shapes = new WeakMap>(); + +/** Indexes of the entities with every component of a list, by its id: one per list in the program. */ +const memberIndexes = new Map>(); + +const membersOf = (required: readonly Component.Key[]) => { + const id = keysId(required); + let index = memberIndexes.get(id); + if (index === undefined) memberIndexes.set(id, index = Index.create({ name: `Observe.entity(${labelOf(required)})`, key: [], components: { has: [...required] } })); + return index; +}; + +/** A by-entity lookup's row: whether a component it watches changed since its system last ran. */ +type PresenceRow = { readonly changed: boolean }; + +/** A by-entity lookup's row of the components it reads, and whether one changed since its system last ran. */ +type LookupRow = { readonly changed: boolean } & { readonly [K in Component.Key]: unknown }; + +/** What a pass finds an entity by: whether it has the required components and they changed, their values, and an optional one's. */ +type Finders = { + readonly presence: (entity: Entity) => PresenceRow | null; + readonly values: (entity: Entity) => { readonly [K in Component.Key]?: unknown } | null; + readonly optional: (index: number, entity: Entity) => LookupRow | null; +}; + +const createShape = (state: WorldState, byShape: Map, id: string, required: readonly Component.Key[], optional: readonly Component.Key[]): EntityShape => { + const name = ownName(`Observe.entity(${labelOf(required)}${optional.length === 0 ? "" : `; ${labelOf(optional)}`})`); + const rows: (LookupRow | null)[] = optional.map(() => null); + /** + * Reads an entry's entity, giving its value if it changed and first values to subscribers waiting up to `through`: + * its required values only once they changed, a new object each time. + */ + const evaluate = (entry: EntityEntry, finders: Finders, through: number, failed: unknown): unknown => { + const found = finders.presence(entry.entity); + let changed: boolean; + if (found === null) changed = !entry.valid || entry.value !== null; + else { + changed = !entry.valid || entry.value === null || found.changed; + for (let i = 0; i < optional.length; i++) { + const row = rows[i] = finders.optional(i, entry.entity); + const present = row !== null; + if (present !== entry.present[i] || (present && row.changed)) changed = true; + } + } + if (changed) { + if (found === null) entry.value = null; + else if (optional.length === 0) entry.value = finders.values(entry.entity); + else { + const value: { [key: symbol]: unknown } = {}; + const values = required.length === 0 ? null : finders.values(entry.entity); + for (let i = 0; i < required.length; i++) value[required[i]] = values?.[required[i]]; + for (let i = 0; i < optional.length; i++) { + const row = rows[i]; + entry.present[i] = row !== null; + if (row !== null) value[optional[i]] = row[optional[i]]; + } + entry.value = value; + } + entry.valid = true; + } + if (!changed && entry.waiting === 0) return failed; + return give(entry, through, changed, state.pass, -1, failed); + }; + /** Drops entries found without subscribers in three passes, as shapes are; returns whether any is left without them. */ + const sweep = (): boolean => { + const { entries } = shape; + let kept = 0; + let idle = false; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.stopped > 0) sweepSubscribers(entry); + if (entry.live === 0) { + if (++entry.idle > 2) { + shape.byEntity.delete(entry.entity); + continue; + } + idle = true; + } + entries[kept++] = entry; + } + entries.length = kept; + return idle; + }; + /** Evaluates every entry, or with nothing changed, those whose subscribers wait for a first value. */ + const pass = (changed: boolean, finders: Finders): void => { + passed(state); + if (!shape.swept) shape.swept = !sweep(); + if (!changed && !shape.waiting) return; + const through = state.delivered; + const { entries } = shape; + let failed: unknown = none; + let waiting = false; + for (let i = 0, count = entries.length; i < count; i++) { + const entry = entries[i]; + if (changed || (entry.waiting > 0 && !entry.valid)) failed = evaluate(entry, finders, through, failed); + else if (entry.waiting > 0) failed = give(entry, through, false, state.pass, -1, failed); + if (entry.waiting > 0) waiting = true; + } + shape.waiting = waiting; + throwFailed(failed); + }; + /** The finders of the runner whose lookups they are: another, once its system is added again. */ + let finders: Finders | null = null; + let source: object | null = null; + const system = optional.length === 0 + ? System.create({ name, observer: true, lookup: { presence: { has: required, changed: required }, values: { read: required }, members: { index: membersOf(required), changed: required } } }, ({ lookup }) => { + if (source !== lookup || finders === null) { + source = lookup; + finders = { presence: lookup.presence, values: lookup.values, optional: noOptional }; + } + pass(lookup.members().changed, finders); + }) + : System.create({ name, observer: true, lookup: optionalLookups(required, optional) }, ({ lookup }) => { + if (source !== lookup || finders === null) { + source = lookup; + finders = { presence: lookup.presence, values: lookup.values, optional: (index, entity) => lookup[index](entity) }; + } + pass(true, finders); + }); + const shape: EntityShape = { + live: 0, + idle: 0, + system, + read: { + system, + flushed: noDelivery, + delivered: batch => { + const { entries } = shape; + for (let i = 0; i < entries.length; i++) giveFirst(state, entries[i], batch); + }, + }, + attached: false, + entries: [], + byEntity: new Map(), + waiting: false, + swept: true, + remove: () => { + shape.attached = false; + shape.entries.length = 0; + shape.byEntity.clear(); + state.world.remove(system); + }, + }; + byShape.set(id, shape); + return shape; +}; + +const noOptional = (): null => null; + +type Watched = { readonly read: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; + +/** Lookups of an entity's required components, whether and what, and one of each optional one, by its position. */ +const optionalLookups = (required: readonly Component.Key[], optional: readonly Component.Key[]): { + readonly presence: { readonly has: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; + readonly values: { readonly read: readonly Component.Key[] }; + readonly [index: number]: Watched; +} => { + const lookups: { presence: { readonly has: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; values: { readonly read: readonly Component.Key[] }; [index: number]: Watched } = { + presence: { has: required, changed: required }, + values: { read: required }, + }; + for (let i = 0; i < optional.length; i++) lookups[i] = { read: [optional[i]], changed: [optional[i]] }; + return lookups; +}; + +export const observeEntity = (world: World, entity: Entity, components: { readonly required?: readonly Component.Key[]; readonly optional?: readonly Component.Key[] }): Observe => { + const required = [...components.required ?? []]; + const optional = [...components.optional ?? []]; + const id = `${keysId(required)};${keysId(optional)}`; + return notify => { + const state = stateOf(world); + let byShape = shapes.get(state); + if (byShape === undefined) shapes.set(state, byShape = new Map()); + const shape = byShape.get(id) ?? createShape(state, byShape, id, required, optional); + if (!shape.attached) { + // Throws on a joined thread for components it can't read, adding nothing. + world.add(shape.system); + shape.attached = true; + } + let entry = shape.byEntity.get(entity); + if (entry === undefined) { + entry = { ...createEntry(null), entity, present: optional.map(() => false) }; + shape.byEntity.set(entity, entry); + shape.entries.push(entry); + } + const observed = entry; + // An entity observer's values have the listed components' types, which `members.ts` gives `T` by the lists. + const typed = observed as Entry; + const stop = subscribe(state, shape, typed, notify, () => { + shape.waiting = true; + if (state.joined) scheduleRead(state, shape.read); + else scheduleDelivery(state); + }); + return () => { + if (observed.live > 0) shape.swept = false; + stop(); + }; + }; +}; diff --git a/packages/ecs/src/observe/index.ts b/packages/ecs/src/observe/index.ts new file mode 100644 index 000000000..c527abfa5 --- /dev/null +++ b/packages/ecs/src/observe/index.ts @@ -0,0 +1 @@ +export { Observe } from './observe.js'; diff --git a/packages/ecs/src/observe/members.ts b/packages/ecs/src/observe/members.ts new file mode 100644 index 000000000..d3a596594 --- /dev/null +++ b/packages/ecs/src/observe/members.ts @@ -0,0 +1,42 @@ +import type { Component, Components, Entity, Resource, World } from '../interface/index.js'; +import { observeEntity } from './entity.js'; +import type { Observe } from './observe.js'; +import { observeCount, observeSelect } from './select.js'; +import type { Query } from './query.js'; +import { observeResource } from './resource.js'; +import type { ReadonlyResourceValue, ReadonlyValue } from './values.js'; + +export { toPromise } from './to-promise.js'; +export type { Query } from './query.js'; + +/** + * A resource's value, then a new one after each run or tick that changed it: one whose sync point stamped it, even with + * the same value, a reduced resource when its fold differs. + */ +export const resource = (world: World, resource: R): Observe> => + observeResource(world, resource); + +/** + * An entity's required components and those optional ones it has, or `null` while it's dead or lacks a required one; + * then a new value after each run or tick that wrote a listed component of it, added or removed one, or despawned it. + * Other components' changes don't notify, nor does anything while it stays `null`. Values map over + * `Pick`, so their symbol keys keep the declarations a consumer's emitted types name them by. + */ +export const entity = ( + world: World, + entity: Entity, + components: { readonly required?: R; readonly optional?: O }, +): Observe<({ readonly [K in keyof Pick]: ReadonlyValue } & { readonly [K in keyof Pick]?: ReadonlyValue }) | null> => + observeEntity(world, entity, components); + +/** + * The entities a query matches, sorted by `order` if given, and with a `page`, at most `limit` of them from `offset`; + * then a new value after each run or tick whose result differs. Nothing when it's the same, even if matching entities + * were written. Throws for a page whose offset or limit isn't a non-negative integer. + */ +export const select = (world: World, query: Query): Observe => + observeSelect(world, query); + +/** The number of entities a query matches; then a new one after each run or tick that changed it. */ +export const count = (world: World, query: Omit, "order" | "page">): Observe => + observeCount(world, query); diff --git a/packages/ecs/src/observe/observe.ts b/packages/ecs/src/observe/observe.ts new file mode 100644 index 000000000..5951e5f7c --- /dev/null +++ b/packages/ecs/src/observe/observe.ts @@ -0,0 +1,24 @@ +/** + * Observes a sequence of values: `notify` is called zero or more times, possibly during the call that subscribes, + * possibly later; an observer mustn't depend on either. Returns the function that stops it. + */ +export type Observe = (notify: (value: T) => void) => Unobserve; + +/** Stops notifications; calling it again does nothing. */ +type Unobserve = () => void; + +/** + * Observers of a world, built on observer systems: one per resource and per shape of observer, shared by every + * subscriber of it, notifying once per run or tick whose sync point changed what it gives, and only then. - A first + * value already held for that resource, entity or query, while others observe it, comes during the subscribing call, + * as of the last sync point. Any other comes later, from a run after every run called before the subscribing call, so + * it reflects them; first values of one task come in that order, from one batch of runs. A first value whose notify + * throws is thrown out of band, as an uncaught error, and stops that observer; a later value's is a `"system"` + * failure of the run or tick, named by the observer's system. + * - Selections without `order`, and ties under one, come in entity order. Under `order`, NaN comes after every number. + * - Values are kept warm for subscribers to come while their observers are used, and released once the world's + * passes found them unused in three; a query is kept current through an index only once a subscriber outlives its + * first value, so a first value alone is one scan. + * - A disposed world runs nothing, so a first value that comes later never comes. + */ +export * as Observe from './members.js'; diff --git a/packages/ecs/src/observe/observe.type-test.ts b/packages/ecs/src/observe/observe.type-test.ts new file mode 100644 index 000000000..1757297d4 --- /dev/null +++ b/packages/ecs/src/observe/observe.type-test.ts @@ -0,0 +1,88 @@ +import { Component, Resource, type Entity, type World } from '../interface/index.js'; +import { Observe } from './index.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +declare const world: World; +declare const entity: Entity; + +const point = Symbol("observeTypeTest/point"); +const persistent = Symbol("observeTypeTest/persistent"); +const shared = Symbol("observeTypeTest/shared"); +const time = Symbol("observeTypeTest/time"); +declare module '@adobe/ecs' { + export interface Components { + [point]: { type: { readonly x: number } }; + [persistent]: { type: true }; + [shared]: { type: true }; + } + export interface Resources { + [time]: { type: number }; + } +} +Component.register(point, { type: { type: "object", properties: { x: { type: "number" } } } }); +Component.register(persistent, { type: { const: true } }); +Component.register(shared, { type: { const: true } }); +Resource.register(time, { type: { type: "number", default: 0 } }); + +// The module's `Observe` has the shape of the package root's. +check, (notify: (value: number) => void) => () => void>>(); + +// A resource's value, read-only. +const observedTime: Observe = Observe.resource(world, time); +// @ts-expect-error a resource's value has its type +Observe.resource(world, time)((value: string) => { void value; }); +// @ts-expect-error only resources are observed by key +Observe.resource(world, point); + +// An entity's listed components, required present and optional possibly, or null. +const observedBoth: Observe<({ readonly [point]: { readonly x: number } } & { readonly [shared]?: true }) | null> = Observe.entity(world, entity, { required: [point], optional: [shared] }); +Observe.entity(world, entity, { required: [point] })(value => { + if (value === null) return; + check>(); + // @ts-expect-error only listed components are observed + void value[shared]; + // @ts-expect-error observed values are read-only + value[point].x = 1; +}); +Observe.entity(world, entity, { optional: [point] })(value => { + // @ts-expect-error an optional component may be missing + void value?.[point].x; +}); +// @ts-expect-error the whole-entity form is gone: list the components +Observe.entity(world, entity); +// @ts-expect-error an observed entity may be dead +Observe.entity(world, entity, { required: [point] })((value: { readonly [point]: { readonly x: number } }) => { void value; }); +// @ts-expect-error only components are observed on an entity +Observe.entity(world, entity, { optional: [time] }); + +// Queries: `where` and `order` name only required components; `count` takes no `order` or `page`. +const query: Observe.Query<[typeof persistent]> = { required: [persistent], not: [shared], where: { [persistent]: true }, order: { [persistent]: true } }; +const selected: Observe = Observe.select(world, query); +const selectedAll: Observe = Observe.select(world, {}); +const counted: Observe = Observe.count(world, { required: [persistent], where: { [persistent]: { "!=": true } } }); +const paged: Observe = Observe.select(world, { required: [persistent], order: { [persistent]: true }, page: { offset: 20, limit: 10 } }); +Observe.select(world, { required: [point], where: { [point]: { x: 1 } } })(entities => { + // @ts-expect-error selected entities are read-only + entities.push(entity); +}); +// @ts-expect-error where may only filter required components +Observe.select(world, { required: [persistent], where: { [shared]: true } }); +// @ts-expect-error order may only sort by required components +Observe.select(world, { required: [persistent], order: { [shared]: true } }); +// @ts-expect-error a where value has its component's type +Observe.select(world, { required: [point], where: { [point]: { x: "one" } } }); +// @ts-expect-error count has no order +Observe.count(world, { required: [persistent], order: { [persistent]: true } }); +// @ts-expect-error count has no page +Observe.count(world, { required: [persistent], page: { offset: 0, limit: 10 } }); +// @ts-expect-error a page has an offset and a limit +Observe.select(world, { required: [persistent], page: { limit: 10 } }); +// @ts-expect-error queries list only components +Observe.select(world, { required: [time] }); + +// The first value of any observer. +const first: Promise = Observe.toPromise(Observe.count(world, {})); + +void [observedTime, observedBoth, selected, selectedAll, counted, paged, first]; diff --git a/packages/ecs/src/observe/plans.ts b/packages/ecs/src/observe/plans.ts new file mode 100644 index 000000000..fab3f14a4 --- /dev/null +++ b/packages/ecs/src/observe/plans.ts @@ -0,0 +1,272 @@ +import type { Component, Entity } from '../interface/index.js'; +import { compare, compareNumbers } from './compare.js'; + +// What a query's rows are gathered into, row by row, from a scan or an index's list: a count; every entity, put in +// entity order once gathered; or by `order`, a bounded heap of the first `offset + limit`, or every row, sorted once +// gathered. Ties under `order` go in entity order. Scratch is typed and reused, so gathering allocates only to grow. + +/** A row's values by component, as systems' rows and index lookups' rows give them. */ +export type Values = { readonly [K in Component.Key]?: unknown }; + +/** One term of an `order`: its component and direction. */ +export type Term = { readonly key: Component.Key; readonly ascending: boolean }; + +export type Page = { readonly offset: number; readonly limit: number }; + +/** Gathers rows, then writes the result, entities into `out` from 0, returning how many, or for a count, the count. */ +export type Gather = { + readonly start: () => void; + readonly add: (entity: number, values: Values) => void; + readonly finish: (out: Entity[]) => number; + /** How many entities `finish` would write now, so a result can be made at its length. */ + readonly size: () => number; +}; + +/** An entity a world gave out, held as a number in typed scratch. */ +// The world's entities are numbers, which scratch arrays hold as they were given. +export const asEntity = (value: number): Entity => value as Entity; + +/** An entity's index: its low 32 bits, which order live entities, as an index without a key keeps them. */ +export const indexOf = (entity: number): number => entity >>> 0; + +export const createCount = (): Gather => { + let count = 0; + return { + start: () => { count = 0; }, + add: () => { count++; }, + finish: () => count, + size: () => count, + }; +}; + +/** By entity index, the entity there while sorting, else 0: shared, and cleared after each use. */ +let bucket = new Float64Array(0); + +/** Writes `count` entities of `from` into `out` in entity order. */ +const toEntityOrder = (from: Float64Array, count: number, largest: number, out: Entity[]): void => { + if (bucket.length <= largest) bucket = new Float64Array(Math.max(largest + 1, 2 * bucket.length)); + for (let i = 0; i < count; i++) bucket[indexOf(from[i])] = from[i]; + if (count * 16 >= largest) { + let n = 0; + for (let index = 0; index <= largest && n < count; index++) { + const entity = bucket[index]; + if (entity === 0) continue; + bucket[index] = 0; + out[n++] = asEntity(entity); + } + return; + } + const indexes = new Uint32Array(count); + for (let i = 0; i < count; i++) indexes[i] = indexOf(from[i]); + indexes.sort(); + for (let i = 0; i < count; i++) { + out[i] = asEntity(bucket[indexes[i]]); + bucket[indexes[i]] = 0; + } +}; + +/** + * A new array of `count` entities of `from` in entity order: as they are when `ordered`, else sorted, `largest` the + * largest index among them. + */ +export const inEntityOrder = (from: Float64Array, count: number, largest: number, ordered: boolean): Entity[] => { + const out = new Array(count); + if (ordered) for (let i = 0; i < count; i++) out[i] = asEntity(from[i]); + else toEntityOrder(from, count, largest, out); + return out; +}; + +/** Every entity, in entity order once gathered. */ +export const createAll = (): Gather => { + let entities = new Float64Array(64); + let count = 0; + let largest = 0; + let ordered = true; + return { + start: () => { + count = 0; + largest = 0; + ordered = true; + }, + add: entity => { + if (count === entities.length) { + const grown = new Float64Array(2 * entities.length); + grown.set(entities); + entities = grown; + } + const index = indexOf(entity); + if (index < largest) ordered = false; + else largest = index; + entities[count++] = entity; + }, + finish: out => { + if (ordered) { + for (let i = 0; i < count; i++) out[i] = asEntity(entities[i]); + return count; + } + toEntityOrder(entities, count, largest, out); + return count; + }, + size: () => count, + }; +}; + +/** + * Rows held by slot: each one's entity, and per term its value, a number in `numbers` where `isNumber` says so, so + * numeric keys aren't boxed. + */ +type Slots = { + entities: Float64Array; + numbers: Float64Array[]; + isNumber: Uint8Array[]; + values: unknown[][]; +}; + +const createSlots = (terms: number, length: number): Slots => ({ + entities: new Float64Array(length), + numbers: Array.from({ length: terms }, () => new Float64Array(length)), + isNumber: Array.from({ length: terms }, () => new Uint8Array(length)), + values: Array.from({ length: terms }, () => new Array(length)), +}); + +const growSlots = (slots: Slots, length: number): void => { + const entities = new Float64Array(length); + entities.set(slots.entities); + slots.entities = entities; + for (let t = 0; t < slots.numbers.length; t++) { + const numbers = new Float64Array(length); + numbers.set(slots.numbers[t]); + slots.numbers[t] = numbers; + const isNumber = new Uint8Array(length); + isNumber.set(slots.isNumber[t]); + slots.isNumber[t] = isNumber; + slots.values[t].length = length; + } +}; + +const store = (slots: Slots, terms: readonly Term[], at: number, entity: number, values: Values): void => { + slots.entities[at] = entity; + for (let t = 0; t < terms.length; t++) { + const value = values[terms[t].key]; + if (typeof value === "number") { + slots.numbers[t][at] = value; + slots.isNumber[t][at] = 1; + slots.values[t][at] = undefined; + } + else { + slots.isNumber[t][at] = 0; + slots.values[t][at] = value; + } + } +}; + +const move = (slots: Slots, from: number, to: number): void => { + slots.entities[to] = slots.entities[from]; + for (let t = 0; t < slots.numbers.length; t++) { + slots.numbers[t][to] = slots.numbers[t][from]; + slots.isNumber[t][to] = slots.isNumber[t][from]; + slots.values[t][to] = slots.values[t][from]; + } +}; + +/** Slots `a` and `b` compared in `order`, then by entity index. */ +const compareSlots = (slots: Slots, terms: readonly Term[], a: number, b: number): number => { + for (let t = 0; t < terms.length; t++) { + const isNumber = slots.isNumber[t]; + const order = isNumber[a] === 1 && isNumber[b] === 1 + ? compareNumbers(slots.numbers[t][a], slots.numbers[t][b]) + : compare(isNumber[a] === 1 ? slots.numbers[t][a] : slots.values[t][a], isNumber[b] === 1 ? slots.numbers[t][b] : slots.values[t][b]); + if (order !== 0) return terms[t].ascending ? order : -order; + } + return indexOf(slots.entities[a]) - indexOf(slots.entities[b]); +}; + +/** + * The first `offset + limit` rows by `order`, or by entity index without terms: a heap of them with the last at its + * root, O(n log(offset + limit)), sorted once gathered. The last two slots hold a candidate and a swap. + */ +export const createHeap = (terms: readonly Term[], page: Page): Gather => { + const bound = page.offset + page.limit; + const slots = createSlots(terms.length, Math.min(bound, 62) + 2); + let size = 0; + const swap = (a: number, b: number): void => { + const spare = slots.entities.length - 1; + move(slots, a, spare); + move(slots, b, a); + move(slots, spare, b); + }; + const siftUp = (from: number): void => { + let at = from; + while (at > 0) { + const parent = (at - 1) >> 1; + if (compareSlots(slots, terms, parent, at) >= 0) return; + swap(parent, at); + at = parent; + } + }; + const siftDown = (from: number, count: number): void => { + let at = from; + for (;;) { + const left = 2 * at + 1; + if (left >= count) return; + const right = left + 1; + const later = right < count && compareSlots(slots, terms, left, right) < 0 ? right : left; + if (compareSlots(slots, terms, at, later) >= 0) return; + swap(at, later); + at = later; + } + }; + return { + start: () => { size = 0; }, + add: (entity, values) => { + if (bound === 0) return; + if (size < bound) { + if (size + 2 >= slots.entities.length) growSlots(slots, Math.min(bound, 2 * slots.entities.length) + 2); + store(slots, terms, size, entity, values); + siftUp(size++); + return; + } + // Before the last kept, it replaces it. + const candidate = slots.entities.length - 2; + store(slots, terms, candidate, entity, values); + if (compareSlots(slots, terms, candidate, 0) >= 0) return; + move(slots, candidate, 0); + siftDown(0, size); + }, + finish: out => { + for (let end = size - 1; end > 0; end--) { + swap(0, end); + siftDown(0, end); + } + let n = 0; + for (let i = page.offset; i < size; i++) out[n++] = asEntity(slots.entities[i]); + for (let t = 0; t < terms.length; t++) slots.values[t].fill(undefined); + return n; + }, + size: () => Math.max(0, size - page.offset), + }; +}; + +/** Every row by `order`, sorted once gathered. */ +export const createSorted = (terms: readonly Term[]): Gather => { + const slots = createSlots(terms.length, 64); + let size = 0; + let positions = new Int32Array(64); + const byOrder = (a: number, b: number): number => compareSlots(slots, terms, a, b); + return { + start: () => { size = 0; }, + add: (entity, values) => { + if (size === slots.entities.length) growSlots(slots, 2 * size); + store(slots, terms, size++, entity, values); + }, + finish: out => { + if (positions.length < size) positions = new Int32Array(slots.entities.length); + for (let i = 0; i < size; i++) positions[i] = i; + const sorted = positions.subarray(0, size).sort(byOrder); + for (let i = 0; i < size; i++) out[i] = asEntity(slots.entities[sorted[i]]); + for (let t = 0; t < terms.length; t++) slots.values[t].fill(undefined, 0, size); + return size; + }, + size: () => size, + }; +}; diff --git a/packages/ecs/src/observe/query.ts b/packages/ecs/src/observe/query.ts new file mode 100644 index 000000000..461c1defd --- /dev/null +++ b/packages/ecs/src/observe/query.ts @@ -0,0 +1,26 @@ +import type { Component } from '../interface/index.js'; + +type Comparison = { + readonly "=="?: T; + readonly "!="?: T; + readonly "<"?: T; + readonly ">"?: T; + readonly "<="?: T; + readonly ">="?: T; +}; + +type ValuesOf = { readonly [K in C[number]]: Component.Value }; + +/** + * Entities that have every component in `required`, every entity but the resource entity without it, and none in + * `not`, filtered by `where`, a plain value meaning equality, and sorted by `order`, `true` for ascending, both naming + * only required components; then sliced by `page`. + */ +export type Query = { + readonly required?: R; + readonly not?: readonly Component.Key[]; + readonly where?: { readonly [K in keyof ValuesOf]?: ValuesOf[K] | Comparison[K]> }; + readonly order?: { readonly [K in keyof ValuesOf]?: boolean }; + /** Only these results, after `where` and `order`. Without `order` they follow entity order. */ + readonly page?: { readonly offset: number; readonly limit: number }; +}; diff --git a/packages/ecs/src/observe/resource.ts b/packages/ecs/src/observe/resource.ts new file mode 100644 index 000000000..22c3266e7 --- /dev/null +++ b/packages/ecs/src/observe/resource.ts @@ -0,0 +1,80 @@ +import { System, type Resource, type World } from '../interface/index.js'; +import type { Observe } from './observe.js'; +import { createEntry, give, giveFirst, none, noDelivery, ownName, passed, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; + +/** + * A resource's observers, sharing one value: `notifier`, added, gives the value after each pass whose sync point + * stamped it; `reader`, run for cold first values, gives them the value as of its run's sync point, which a stamp + * before it then doesn't give them again in the same pass. + */ +type ResourceShape = Shape & { + readonly entry: Entry; + readonly read: Read; + /** + * On a joined thread, a run of the notifier: its first run once added passes its filter, as the world starts it + * from tick 0, so once it resolved the entry holds the value. + */ + readonly joinedRead: Read; + readonly notifier: System; + /** Whether the notifier is added: it's removed once the shape has been idle for a pass, and added again on use. */ + attached: boolean; + /** The flushes the reader's runs were called for, oldest first. */ + readonly called: number[]; +}; + +/** By world, its resources' shapes, kept for its life, so their systems register in it once. */ +const shapes = new WeakMap>(); + +const createShape = (state: WorldState, byKey: Map, key: Resource.Key): ResourceShape => { + const entry = createEntry(undefined); + const label = ownName(`Observe.resource(${key.description ?? "?"})`); + // On a joined thread, first values come once a run of this system resolved, never in its pass: none to skip. + const { joined } = state; + const notifier = System.create({ name: label, observer: true, resources: { read: [key], changed: [key] } }, ({ resources }) => { + passed(state); + entry.value = resources[key]; + entry.valid = true; + if (entry.stopped > 0) sweepSubscribers(entry); + throwFailed(give(entry, 0, true, state.pass, joined ? -1 : state.pass, none)); + }); + const reader = System.create({ name: label, observer: true, resources: { read: [key] } }, ({ resources }) => { + const through = shape.called.shift() ?? 0; + entry.value = resources[key]; + entry.valid = true; + // The maintenance system counts this pass after the run's own systems, before the notifier runs in it. + give(entry, through, false, state.pass + 1, -1, none); + }); + const shape: ResourceShape = { + live: 0, + idle: 0, + entry, + called: [], + read: { system: reader, flushed: batch => { shape.called.push(batch); }, delivered: noDelivery }, + joinedRead: { system: notifier, flushed: noDelivery, delivered: batch => { giveFirst(state, entry, batch); } }, + notifier, + attached: false, + remove: () => { + shape.attached = false; + entry.valid = false; + state.world.remove(notifier); + }, + }; + byKey.set(key, shape); + return shape; +}; + + +export const observeResource = (world: World, key: Resource.Key): Observe => notify => { + const state = stateOf(world); + let byKey = shapes.get(state); + if (byKey === undefined) shapes.set(state, byKey = new Map()); + const shape = byKey.get(key) ?? createShape(state, byKey, key); + if (!shape.attached) { + // Throws on a joined thread for a resource it can't read, adding nothing. + world.add(shape.notifier); + shape.attached = true; + } + // A resource key's values have its type, which `members.ts` gives `T` by the key's declaration. + const entry = shape.entry as Entry; + return subscribe(state, shape, entry, notify, () => scheduleRead(state, state.joined ? shape.joinedRead : shape.read)); +}; diff --git a/packages/ecs/src/observe/select.ts b/packages/ecs/src/observe/select.ts new file mode 100644 index 000000000..a32fff96c --- /dev/null +++ b/packages/ecs/src/observe/select.ts @@ -0,0 +1,648 @@ +import { Index, System, type Component, type Entity, type World } from '../interface/index.js'; +import { compare, predicateOf } from './compare.js'; +import type { Observe } from './observe.js'; +import { createAll, createCount, createHeap, createSorted, inEntityOrder, indexOf, type Gather, type Page, type Term, type Values } from './plans.js'; +import { + createEntry, deliverAlso, give, giveFirst, keysId, labelOf, none, noDelivery, numberOf, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, + type Entry, type Read, type Shape, type WorldState, +} from './world-state.js'; + +/** A query as `select` and `count` take it, without its types. */ +type AnyQuery = { + readonly required?: readonly Component.Key[]; + readonly not?: readonly Component.Key[]; + readonly where?: object; + readonly order?: object; + readonly page?: Page; +}; + +/** No range: a ranged shape's entry whose condition on the ranged key bounds nothing an index can, such as `"!="`. */ +const noRange: unique symbol = Symbol("Observe/noRange"); + +/** + * A query's observers with one `where` and page: its value, its `where` tests in its shape's order, and what gathers + * it. Through a sorted index on one `where` key, its range there: its condition with each bound made inclusive, which + * holds every row the condition passes whatever the precision values are kept at; and, for a count it gives alone, the + * lookups of the rows at each bound, which only the condition's own test can tell apart. + */ +type QueryEntry = Entry & { + readonly id: string; + readonly tests: readonly ((value: unknown) => boolean)[]; + readonly page: Page | null; + readonly gather: Gather; + readonly range: Bound | typeof noRange; + readonly bounds: readonly Bound[] | null; +}; + +/** + * A scan for cold first values, of the entries waiting for one flush's: started by its first row, in its run, and + * finished in the delivering run after it, which runs before anything else could change the world. + */ +type Scan = { readonly batch: number; started: boolean; readonly entries: QueryEntry[] }; + +/** + * The observers of one shape of query, its components, `where` keys and `order`, each `where` and page an entry. Cold + * first values come from a scan of its rows while no subscriber outlived its first value; then `tracker`, added, keeps + * every entry's value through an index: a sorted one on `order` when every term goes one direction; else, for a count + * or a selection without a page, a sorted one on its first `where` key whose values have an order, each entry looking + * up its range; else one without a key, of what matches. + */ +type QueryShape = Shape & { + readonly entries: QueryEntry[]; + readonly byId: Map; + readonly read: Read; + /** + * On a joined thread, where a scan's rows can't be told from a later post's: the tracker, added on first use, + * and a run of it for first values, which once resolved gives those its pass read. + */ + readonly tracker: System; + readonly joinedRead: Read; + readonly scans: Scan[]; + /** The position in `where` of the key the tracker's index is sorted by, or -1. */ + readonly ranged: number; + tracking: boolean; + waiting: boolean; + swept: boolean; +}; + +const shapes = new WeakMap>(); + +/** An index's list of rows, as a lookup through it gives them. */ +type Rows = { readonly length: number; readonly changed: boolean; at(index: number): { readonly entity: Entity } & Values }; + +/** Indexes the trackers look up through, by declaration: one each in the program. */ +const keyless = new Map>(); +const sortedIndexes = new Map | null>(); + +/** + * Whether `not` excludes none of `held`, an index's key parts and `has`: what an index declaration's type checks of + * lists it can see, which these are only at run time. + */ +const excludesNone = (not: readonly Component.Key[], held: readonly Component.Key[]): not is readonly never[] => { + for (let i = 0; i < not.length; i++) if (held.includes(not[i])) return false; + return true; +}; + +/** `not`, which `observeQuery` made exclude no required component, as an index declaration takes it. */ +const excluding = (not: readonly Component.Key[], held: readonly Component.Key[]): readonly never[] => { + if (!excludesNone(not, held)) throw new Error("An index can't exclude what it holds by."); + return not; +}; + +const keylessOf = (has: readonly Component.Key[], not: readonly Component.Key[]) => { + const id = `${keysId(has)}|${keysId(not)}`; + let index = keyless.get(id); + if (index === undefined) keyless.set(id, index = Index.create({ name: `Observe.query(${labelOf(has)})`, key: [], components: { has: [...has], not: excluding(not, has) } })); + return index; +}; + +/** A sorted index on `key`, or null where one can't be, as for a part whose values have no order. */ +const sortedOf = (key: readonly Component.Key[], has: readonly Component.Key[], not: readonly Component.Key[]) => { + const id = `${keysId(key)}|${keysId(has)}|${keysId(not)}`; + let index = sortedIndexes.get(id); + if (index === undefined) { + try { + index = Index.create({ name: `Observe.query(${labelOf(has)}) by ${labelOf(key)}`, key: [...key], sorted: true, components: { has: [...has], not: excluding(not, [...key, ...has]) } }); + } + catch { + index = null; + } + sortedIndexes.set(id, index); + } + return index; +}; + +const isCount = (value: number): boolean => Number.isInteger(value) && value >= 0; + +/** A `where` value's identity: equal values give the same text. */ +const identityOf = (value: unknown): string => { + if (typeof value === "number") return Object.is(value, -0) ? "n0" : `n${value}`; + if (typeof value === "string") return `s${JSON.stringify(value)}`; + if (typeof value === "boolean") return value ? "t" : "f"; + if (value === null) return "z"; + if (value === undefined) return "u"; + if (Array.isArray(value)) return `[${value.map(identityOf).join(",")}]`; + if (typeof value === "object") return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${identityOf(Reflect.get(value, key))}`).join(",")}}`; + return `?${String(value)}`; +}; + +const unique = (keys: readonly Component.Key[]): Component.Key[] => [...new Set(keys)]; +const byNumber = (a: symbol, b: symbol): number => numberOf(a) - numberOf(b); + +const operators = new Set(["==", "!=", "<", ">", "<=", ">="]); + +/** + * A condition's range as a sorted index's lookup takes it, every bound inclusive, so it holds every row the condition + * passes at whatever precision the index keeps values; with each bound, or noRange where it bounds nothing an index + * can. `exact` is whether the range holds no more than rows at its bounds: no `"!="` was left out. + */ +type Range = { readonly range: Bound | typeof noRange; readonly bounds: readonly Value[]; readonly exact: boolean }; +const unbounded: Range = { range: noRange, bounds: [], exact: false }; + +const rangeOf = (condition: unknown): Range => { + if (isValue(condition)) return { range: condition, bounds: [condition], exact: true }; + if (typeof condition !== "object" || condition === null) return unbounded; + const entries = Object.entries(condition); + if (entries.length === 0 || !entries.every(([operator]) => operators.has(operator))) return unbounded; + const range: { "<="?: Value; ">="?: Value; "=="?: Value } = {}; + const bounds: Value[] = []; + let exact = true; + for (const [operator, operand] of entries) { + if (operator === "!=" || !isValue(operand)) { + exact = false; + continue; + } + if (operator === "<" || operator === "<=") range["<="] = operand; + else if (operator === ">" || operator === ">=") range[">="] = operand; + else range["=="] = operand; + bounds.push(operand); + } + return bounds.length === 0 ? unbounded : { range, bounds, exact }; +}; + +type Value = Component.Value; + +/** A value or comparison a sorted index on one key part looks up by. */ +type Bound = Value | { readonly "<="?: Value; readonly ">="?: Value; readonly "=="?: Value }; + +/** + * Whether a `where` operand is a value of a part a sorted index orders: a number, boolean, string, entity or tuple of + * them, never a plain object, which would be a comparison. + */ +const isValue = (operand: unknown): operand is Value => + operand !== undefined && (typeof operand !== "object" || Array.isArray(operand)); + +/** A sorted index on one key part, or null where one can't be. */ +const rangedOf = (key: Component.Key, has: readonly Component.Key[], not: readonly Component.Key[]) => { + const id = `${keysId([key])}|${keysId(has)}|${keysId(not)}`; + let index = rangedIndexes.get(id); + if (index === undefined) { + try { + index = Index.create({ name: `Observe.query(${labelOf(has)}) by ${labelOf([key])}`, key: [key], sorted: true, components: { has: [...has], not: excluding(not, [key, ...has]) } }); + } + catch { + index = null; + } + rangedIndexes.set(id, index); + } + return index; +}; +const rangedIndexes = new Map | null>(); + +const createShape = (state: WorldState, byShape: Map, id: string, counting: boolean, impossible: boolean, paged: boolean, required: readonly Component.Key[], not: readonly Component.Key[], where: readonly Component.Key[], terms: readonly Term[]): QueryShape => { + const reads = unique([...where, ...terms.map(term => term.key)]); + const has = unique([...required, ...reads]); + const name = ownName(`Observe.${counting ? "count" : "select"}(${labelOf(required)})`); + const direction = terms.length > 0 && terms.every(term => term.ascending === terms[0].ascending) ? terms[0].ascending : null; + const sortedKey = terms.map(term => term.key); + // On a joined thread, a selection without a page sorts there, through an index without a key, rather than having + // the world's thread keep a sorted one: its whole result is read each pass either way. + const ordered = !counting && direction !== null && (paged || !state.joined) ? sortedOf(sortedKey, has.filter(key => !sortedKey.includes(key)), not) : null; + let ranged = -1; + let byRange: ReturnType = null; + if (ordered === null && (counting || !paged || terms.length > 0)) { + for (let r = 0; r < where.length && byRange === null; r++) { + byRange = rangedOf(where[r], has.filter(key => key !== where[r]), not); + if (byRange !== null) ranged = r; + } + } + /** Reused for results being compared with the last. */ + const out: Entity[] = []; + + const passes = (entry: QueryEntry, values: Values): boolean => { + if (impossible) return false; + const { tests } = entry; + for (let k = 0; k < tests.length; k++) if (!tests[k](values[where[k]])) return false; + return true; + }; + + /** Gathers the rows of a list that pass an entry's `where`, writing its result into `out`; returns its length or count. */ + const gathered = (entry: QueryEntry, rows: Rows): number => { + const length = impossible ? 0 : rows.length; + const { gather } = entry; + gather.start(); + for (let i = 0; i < length; i++) { + const row = rows.at(i); + if (passes(entry, row)) gather.add(row.entity, row); + } + return gather.finish(out); + }; + + /** Writes an entry's result into `out` through a list of rows in entity order, and returns its length or count. */ + const fromKeyless = (entry: QueryEntry, rows: Rows): number => { + const length = impossible ? 0 : rows.length; + if (counting && entry.tests.length === 0) return length; + if (counting || terms.length > 0) return gathered(entry, rows); + const { page } = entry; + const limit = page === null ? length : page.limit; + let skip = page === null ? 0 : page.offset; + let n = 0; + if (entry.tests.length === 0) { + for (let i = skip; i < length && n < limit; i++) out[n++] = rows.at(i).entity; + return n; + } + for (let i = 0; i < length && n < limit; i++) { + const row = rows.at(i); + if (!passes(entry, row)) continue; + if (skip > 0) skip--; + else out[n++] = row.entity; + } + return n; + }; + + /** A run of rows with equal keys, read from its last, passing `where`: their entities, highest position first. */ + let run = new Float64Array(64); + /** The key of the run in progress, by term. */ + const tie: unknown[] = terms.map(() => undefined); + const sameKey = (values: Values): boolean => { + for (let t = 0; t < terms.length; t++) if (compare(values[terms[t].key], tie[t]) !== 0) return false; + return true; + }; + + /** Writes an entry's result into `out` through a sorted index's rows, ties in entity order whichever the direction. */ + const fromSorted = (entry: QueryEntry, rows: Rows): number => { + const length = impossible ? 0 : rows.length; + const { page } = entry; + const limit = page === null ? length : page.limit; + let skip = page === null ? 0 : page.offset; + let n = 0; + const filtered = entry.tests.length > 0; + if (direction === true) { + if (!filtered) { + for (let i = skip; i < length && n < limit; i++) out[n++] = rows.at(i).entity; + return n; + } + for (let i = 0; i < length && n < limit; i++) { + const row = rows.at(i); + if (!passes(entry, row)) continue; + if (skip > 0) skip--; + else out[n++] = row.entity; + } + return n; + } + // Descending: from the last row, each run of equal keys gathered, then given in entity order, its first + // position first. + let held = 0; + for (let i = length - 1; i >= -1 && n < limit; i--) { + const row = i < 0 ? null : rows.at(i); + if (row === null || i === length - 1 || !sameKey(row)) { + for (let k = held - 1; k >= 0 && n < limit; k--) { + if (skip > 0) skip--; + else out[n++] = entityOf(run[k]); + } + held = 0; + if (row === null) break; + for (let t = 0; t < terms.length; t++) tie[t] = row[terms[t].key]; + } + if (filtered && !passes(entry, row)) continue; + if (held === run.length) { + const grown = new Float64Array(2 * run.length); + grown.set(run); + run = grown; + } + run[held++] = row.entity; + } + return n; + }; + + /** Writes an entry's result into `out` through its range of a sorted index on one `where` key; returns its length or count. */ + const fromRange = (entry: QueryEntry, rows: Rows, find: (range: Bound) => Rows): number => { + const { bounds } = entry; + if (impossible) return 0; + // A count of one condition: the range's length, less the rows at a bound the condition doesn't pass. Rows at a + // bound share its key, so its value: one tells for all. + if (bounds === null) return gathered(entry, rows); + const total = rows.length; + if (total === 0) return 0; + if (entry.range === bounds[0] && bounds.length === 1) return entry.tests[0](rows.at(0)[where[0]]) ? total : 0; + let count = total; + let counted = -1; + for (let b = 0; b < bounds.length; b++) { + const at = find(bounds[b]); + if (at.length === 0) continue; + const first = at.at(0); + // Two bounds the index keeps at one key are one run. + if (first.entity === counted) continue; + counted = first.entity; + if (!entry.tests[0](first[where[0]])) count -= at.length; + } + return count; + }; + + /** Whether the first `count` of `out` are an entry's last value. */ + const same = (entry: QueryEntry, count: number): boolean => { + if (counting) return entry.value === count; + const last = entry.value; + if (!Array.isArray(last) || last.length !== count) return false; + for (let i = 0; i < count; i++) if (last[i] !== out[i]) return false; + return true; + }; + + /** Sets an entry's value from its result, unless it's the last; returns whether it changed. */ + const settle = (entry: QueryEntry, count: number): boolean => { + if (entry.valid && same(entry, count)) return false; + entry.value = counting ? count : out.slice(0, count); + entry.valid = true; + return true; + }; + + /** Drops entries found without subscribers in three passes, as shapes are; returns whether any is left without them. */ + const sweep = (): boolean => { + const { entries } = shape; + let kept = 0; + let idle = false; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.stopped > 0) sweepSubscribers(entry); + if (entry.live === 0) { + if (!shape.tracking || ++entry.idle > 2) { + shape.byId.delete(entry.id); + continue; + } + idle = true; + } + entries[kept++] = entry; + } + entries.length = kept; + return idle; + }; + + /** + * Keeps every entry's value: through `rows`, one list for all, or each entry's own, `own`; with nothing changed, + * gives waiting subscribers theirs. + */ + const track = (rows: Rows | null, own: ((entry: QueryEntry) => number) | null, from: (entry: QueryEntry, rows: Rows) => number): void => { + passed(state); + if (!shape.swept) shape.swept = !sweep(); + const changed = rows === null || rows.changed; + if (!changed && !shape.waiting) return; + const through = state.delivered; + const { entries } = shape; + let failed: unknown = none; + let waiting = false; + for (let i = 0, count = entries.length; i < count; i++) { + const entry = entries[i]; + if (own !== null) { + const result = own(entry); + if (result >= 0 || entry.waiting > 0) failed = give(entry, through, result >= 0 && settle(entry, result), state.pass, -1, failed); + } + else if (rows !== null && (changed || (entry.waiting > 0 && !entry.valid))) failed = give(entry, through, settle(entry, from(entry, rows)), state.pass, -1, failed); + else if (entry.waiting > 0) failed = give(entry, through, false, state.pass, -1, failed); + if (entry.waiting > 0) waiting = true; + } + shape.waiting = waiting; + throwFailed(failed); + }; + + /** An entry's result through its range, or -1 while it's valid and nothing in its range changed. */ + const ownRange = (entry: QueryEntry, find: (range: Bound) => Rows, all: () => Rows): number => { + const rows = entry.range === noRange ? all() : find(entry.range); + if (entry.valid && !rows.changed) return -1; + return entry.range === noRange ? gathered(entry, rows) : fromRange(entry, rows, find); + }; + + const tracker = ordered !== null + ? System.create({ name, observer: true, lookup: { list: { index: ordered, read: reads, changed: reads } } }, ({ lookup }) => { + track(lookup.list(), null, fromSorted); + }) + : byRange !== null + ? System.create({ name, observer: true, lookup: { list: { index: byRange, read: reads, changed: reads } } }, ({ lookup }) => { + // A range is of a `where` condition on the index's key, which the query's type checked against its + // values. + track(null, entry => ownRange(entry, range => lookup.list(range as Parameters[0]), () => lookup.list()), fromSorted); + }) + : System.create({ name, observer: true, lookup: { list: { index: keylessOf(has, not), read: reads, changed: reads } } }, ({ lookup }) => { + track(lookup.list(), null, fromKeyless); + }); + + /** What takes each row of the scan in progress, which its first row starts, until it's finished. */ + let accept: ((row: { readonly entity: Entity } & Values) => void) | null = null; + const ignore = (): void => {}; + /** One entry's acceptance, its test inline when it has one; several entries' in turn. */ + const acceptOf = (entries: readonly QueryEntry[]): (row: { readonly entity: Entity } & Values) => void => { + if (entries.length !== 1 || impossible) return row => { + for (let i = 0; i < entries.length; i++) if (passes(entries[i], row)) entries[i].gather.add(row.entity, row); + }; + const [{ gather, tests }] = entries; + if (tests.length === 0) return row => { gather.add(row.entity, row); }; + if (tests.length === 1) { + const [test] = tests; + const [key] = where; + return row => { if (test(row[key])) gather.add(row.entity, row); }; + } + const [entry] = entries; + return row => { if (passes(entry, row)) gather.add(row.entity, row); }; + }; + /** + * A scan of one entry with at most one test, every entity or a count, the scanner's body gathers itself, its test + * inline, without a call per row through `accept` and a gather: 1 for entities, 2 for a count, else 0. Entities go + * into `found`, `foundOrdered` while they come in entity order, `foundLargest` the largest index so far. + */ + let direct = 0; + let directTest: ((value: unknown) => boolean) | null = null; + const directKey: Component.Key | undefined = where[0]; + let found = new Float64Array(64); + let foundCount = 0; + let foundLargest = 0; + let foundOrdered = true; + const start = (): void => { + const scan = shape.scans[0]; + if (scan === undefined) { + accept = ignore; + return; + } + scan.started = true; + const [first] = scan.entries; + if (scan.entries.length === 1 && !impossible && first.tests.length <= 1 && (counting || (terms.length === 0 && first.page === null))) { + direct = counting ? 2 : 1; + directTest = first.tests.length === 0 ? null : first.tests[0]; + foundCount = 0; + foundLargest = 0; + foundOrdered = true; + accept = ignore; + return; + } + for (const entry of scan.entries) entry.gather.start(); + accept = acceptOf(scan.entries); + }; + const scanner = System.create({ name, observer: true, components: { read: reads, has: required.filter(key => !reads.includes(key)), not } }, row => { + if (accept === null) start(); + if (direct === 0) { + accept?.(row); + return; + } + if (directTest !== null && directKey !== undefined && !directTest(row[directKey])) return; + if (direct === 2) { + foundCount++; + return; + } + const entity = row.entity; + const index = indexOf(entity); + if (index < foundLargest) foundOrdered = false; + else foundLargest = index; + if (foundCount === found.length) { + const grown = new Float64Array(2 * found.length); + grown.set(found); + found = grown; + } + found[foundCount++] = entity; + }); + /** Drops entries without subscribers, which nothing keeps current while the shape isn't tracked. */ + const dropUnused = (): void => { + const { entries } = shape; + let kept = 0; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.live === 0) shape.byId.delete(entry.id); + else entries[kept++] = entry; + } + entries.length = kept; + }; + /** Gives the entries a scan read their first values, and tracks those that still have subscribers after. */ + const finish = (): void => { + const scan = shape.scans.shift(); + if (scan === undefined) return; + accept = null; + const gathered = direct; + direct = 0; + let failed: unknown = none; + let kept = false; + for (const entry of scan.entries) { + if (gathered === 1) entry.value = inEntityOrder(found, foundCount, foundLargest, foundOrdered); + else if (gathered === 2) entry.value = foundCount; + else { + if (!scan.started) entry.gather.start(); + // The value itself is gathered into, made at its length, rather than copied from scratch or grown. + const value = new Array(counting ? 0 : entry.gather.size()); + const count = entry.gather.finish(value); + value.length = count; + entry.value = counting ? count : value; + } + entry.valid = true; + failed = give(entry, scan.batch, false, state.pass, -1, failed); + if (entry.live > 0) kept = true; + // Without a tracker, nothing keeps it current. + else if (!shape.tracking) entry.valid = false; + } + if (kept && !shape.tracking) { + shape.tracking = true; + state.world.add(tracker); + } + if (!shape.tracking) dropUnused(); + throwFailed(failed); + }; + const shape: QueryShape = { + live: 0, + idle: 0, + entries: [], + byId: new Map(), + scans: [], + ranged, + tracking: false, + waiting: false, + swept: true, + read: { + system: scanner, + flushed: batch => { + const waiting = shape.entries.filter(entry => entry.waiting > 0 && !entry.valid); + shape.scans.push({ batch, started: false, entries: waiting }); + deliverAlso(state, finish); + }, + delivered: noDelivery, + }, + tracker, + joinedRead: { + system: tracker, + flushed: noDelivery, + delivered: batch => { + const { entries } = shape; + for (let i = 0; i < entries.length; i++) giveFirst(state, entries[i], batch); + }, + }, + remove: () => { + // Idle for a pass, its entries have no subscribers. + shape.entries.length = 0; + shape.byId.clear(); + if (!shape.tracking) return; + shape.tracking = false; + state.world.remove(tracker); + }, + }; + byShape.set(id, shape); + return shape; +}; + + +// Entities are numbers the world gave out, held as they were in typed scratch. +const entityOf = (value: number): Entity => value as Entity; + +/** What gathers an entry's rows, by its shape and page, for scans and trackers through an index without a key. */ +const gatherOf = (counting: boolean, terms: readonly Term[], page: Page | null): Gather => { + if (counting) return createCount(); + if (terms.length === 0) return page === null ? createAll() : createHeap(terms, page); + return page === null ? createSorted(terms) : createHeap(terms, page); +}; + +/** An entry of a shape, for its `where` conditions and page. */ +const createQueryEntry = (shape: QueryShape, id: string, counting: boolean, terms: readonly Term[], conditions: readonly unknown[], page: Page | null): QueryEntry => { + const tests = conditions.map(predicateOf); + const { range, bounds, exact } = shape.ranged < 0 ? unbounded : rangeOf(conditions[shape.ranged]); + // The count of one condition takes the range's length and its bounds' rows; any other result gathers its rows. + const counted = counting && exact && conditions.length === 1; + const lookups: readonly Bound[] | null = !counted || range === noRange ? null : isValue(range) ? [range] : bounds.map((bound): Bound => ({ ...range, "==": bound })); + return { ...createEntry(undefined), id, tests, page, gather: gatherOf(counting, terms, page), range, bounds: lookups }; +}; + +const observeQuery = (world: World, query: AnyQuery, counting: boolean): Observe => { + const page = counting ? null : query.page ?? null; + if (page !== null && !(isCount(page.offset) && isCount(page.limit))) { + throw new Error(`A query's page needs a non-negative integer offset and limit, not ${String(page.offset)} and ${String(page.limit)}.`); + } + const required = unique(query.required ?? []); + // A component both required and excluded matches nothing, which an index can't hold. + const impossible = (query.not ?? []).some(key => required.includes(key)); + const not = unique((query.not ?? []).filter(key => !required.includes(key))); + // `where` and `order` name only required components. + const listed = (object: object | undefined): readonly Component.Key[] => object === undefined ? [] : Object.getOwnPropertySymbols(object).flatMap(symbol => { + const key = required.find(each => each === symbol); + return key === undefined || !Object.prototype.propertyIsEnumerable.call(object, key) ? [] : [key]; + }); + const where = [...listed(query.where)].sort(byNumber); + const terms: Term[] = listed(query.order).map(key => ({ key, ascending: Reflect.get(query.order ?? {}, key) !== false })); + const conditions = where.map(key => Reflect.get(query.where ?? {}, key)); + const shapeId = `${counting ? "count" : "select"}${impossible ? "!" : ""}${page === null ? "" : "#"}|${keysId(required)}|${keysId(not)}|${keysId(where)}|${terms.map(term => `${numberOf(term.key)}${term.ascending ? "+" : "-"}`).join(",")}`; + const entryId = `${conditions.map(identityOf).join("|")}|${page === null ? "" : `${page.offset},${page.limit}`}`; + return notify => { + const state = stateOf(world); + let byShape = shapes.get(state); + if (byShape === undefined) shapes.set(state, byShape = new Map()); + const shape = byShape.get(shapeId) ?? createShape(state, byShape, shapeId, counting, impossible, page !== null, required, not, where, terms); + if (state.joined && !shape.tracking) { + // Throws for components a joined thread can't read, adding nothing. + world.add(shape.tracker); + shape.tracking = true; + } + let entry = shape.byId.get(entryId); + if (entry === undefined) { + entry = createQueryEntry(shape, entryId, counting, terms, conditions, page); + shape.byId.set(entryId, entry); + shape.entries.push(entry); + } + const observed = entry; + // A select's values are lists of entities and a count's numbers, which its callers give `T` by `counting`. + const typed = observed as Entry; + const stop = subscribe(state, shape, typed, notify, () => { + shape.waiting = true; + if (state.joined) scheduleRead(state, shape.joinedRead); + else if (shape.tracking) scheduleDelivery(state); + else scheduleRead(state, shape.read); + }); + return () => { + if (observed.live > 0) shape.swept = false; + stop(); + }; + }; +}; + +export const observeSelect = (world: World, query: AnyQuery): Observe => observeQuery(world, query, false); +export const observeCount = (world: World, query: AnyQuery): Observe => observeQuery(world, query, true); diff --git a/packages/ecs/src/observe/to-promise.ts b/packages/ecs/src/observe/to-promise.ts new file mode 100644 index 000000000..c9f44e946 --- /dev/null +++ b/packages/ecs/src/observe/to-promise.ts @@ -0,0 +1,22 @@ +import type { Observe } from './observe.js'; + +/** The first value an observer gets; it stops observing after it, even when that value comes during the subscribing call. */ +export const toPromise = (observe: Observe): Promise => new Promise((resolve, reject) => { + let resolved = false; + let stop: (() => void) | null = null; + const tryStop = (): void => { + if (stop !== null) stop(); + else queueMicrotask(tryStop); + }; + try { + stop = observe(value => { + if (resolved) return; + resolved = true; + resolve(value); + tryStop(); + }); + } + catch (error) { + reject(error); + } +}); diff --git a/packages/ecs/src/observe/tsconfig.json b/packages/ecs/src/observe/tsconfig.json new file mode 100644 index 000000000..9e188bd89 --- /dev/null +++ b/packages/ecs/src/observe/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../data/tsconfig-base.json", + "compilerOptions": { + "rootDir": "..", + "outDir": "../../dist", + "tsBuildInfoFile": "../../dist/observe/tsconfig.tsbuildinfo", + "composite": true, + "types": [ + "@webgpu/types" + ], + "paths": { + "@adobe/ecs": [ + "../interface/index.ts" + ] + }, + "lib": [ + "ES2024", + "ESNext.Disposable", + "DOM", + "DOM.Iterable" + ] + }, + "include": [ + "./**/*" + ], + "references": [ + { + "path": "../interface" + } + ] +} diff --git a/packages/ecs/src/observe/values.ts b/packages/ecs/src/observe/values.ts new file mode 100644 index 000000000..af243ba94 --- /dev/null +++ b/packages/ecs/src/observe/values.ts @@ -0,0 +1,9 @@ +import type { Component, Resource } from '../interface/index.js'; + +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +/** Component `K`'s value as observers give it: read-only, with branded primitives such as `Entity` as they are. */ +export type ReadonlyValue = Component.Value extends Primitive ? Component.Value : Readonly>; + +/** Resource `K`'s value as observers give it. */ +export type ReadonlyResourceValue = Resource.Value extends Primitive ? Resource.Value : Readonly>; diff --git a/packages/ecs/src/observe/world-state.ts b/packages/ecs/src/observe/world-state.ts new file mode 100644 index 000000000..3e63990bb --- /dev/null +++ b/packages/ecs/src/observe/world-state.ts @@ -0,0 +1,414 @@ +import { Report, System, type World } from '../interface/index.js'; + +// What the factories keep for each world: the values observers share, their subscribers, and the observer systems that +// read the world for them. Every shape of observer is one observer system, never one per subscriber or entity. + +/** + * A subscriber: its notify, null once stopped; the flush its first value waits for, 0 once it had it; and the pass + * it had it in, so a system later in that pass doesn't notify it again. + */ +export type Subscriber = { notify: ((value: T) => void) | null; batch: number; pass: number }; + +/** + * A value observers share, such as a resource's or an entity's: the last value given, `valid` while it's the world's + * as of the last pass; its subscribers, null where one stopped, `live` of them active and `waiting` for a first value; + * and how many passes found it without subscribers since it last had one. + */ +export type Entry = { + value: T; + valid: boolean; + readonly subscribers: Subscriber[]; + live: number; + waiting: number; + stopped: number; + idle: number; +}; + +export const createEntry = (value: T): Entry => ({ value, valid: false, subscribers: [], live: 0, waiting: 0, stopped: 0, idle: 0 }); + +/** + * What a shape removes once it's been idle for a whole pass, how many subscribers its entries have, and how many passes + * the maintenance system found it without them since it last had one. + */ +export type Shape = { live: number; idle: number; readonly remove: () => void }; + +/** + * A system a flush runs before it delivers, and what it's told of the flush, by number, when it does; on a joined + * thread, `delivered` once the run resolved, a post for it having run there. + */ +export type Read = { readonly system: System; readonly flushed: (batch: number) => void; readonly delivered: (batch: number) => void }; + +/** + * A world's factories' state: the pass the maintenance system counted last; flushes, `open` the one new cold first + * values wait for and `delivered` the last whose delivering run reached its pass; what the next flush runs; and idle + * shapes. + */ +export type WorldState = { + readonly world: World; + /** + * Whether `world` is a joined thread's: its observer systems run there in posts, in no order with one another, so + * a shape's first values come once a run of its own system resolved, rather than from a delivering run. + */ + readonly joined: boolean; + /** On a joined thread, whether this turn of its event loop counted a pass, and what ends it once its posts ran. */ + counted: boolean; + readonly endTurn: () => void; + pass: number; + open: number; + delivered: number; + scheduled: boolean; + readonly reads: Read[]; + /** Whether the next flush runs the delivering system, for shapes that read in the pass of its run. */ + delivering: boolean; + /** Work for the next flush's delivering run, in its body, before shapes' systems run in its pass. */ + tasks: (() => void)[]; + /** By delivering run called and not yet run, its flush and its work. */ + readonly deliveries: { readonly batch: number; readonly tasks: readonly (() => void)[] }[]; + readonly idle: Shape[]; + readonly deliver: System; + /** What a rejected run of the factories' own does. */ + readonly rejected: (error: unknown) => void; + /** + * The factories' own runs, by number, with how many of each one's report's failures were rethrown; and the + * failures of the factories' systems in reports of runs not known to be theirs yet, by run. + */ + /** Run with each of the factories' own systems, as a composite: what reads the reports their runs gave. */ + readonly rethrow: System; + readonly composites: WeakMap; +}; + +const states = new WeakMap(); + +/** Rethrows an error out of band: it's the caller's, but nothing can return it to them. */ +export const rethrowLater = (error: unknown): void => { + queueMicrotask(() => { throw error; }); +}; + +/** + * The names of the systems the factories make. A run of theirs runs every observer system the world added too; only + * these systems' failures are the factories' to rethrow. + */ +const ownNames = new Set(); + +/** Names a system the factories make. */ +export const ownName = (name: string): string => { + ownNames.add(name); + return name; +}; + +const ignore = (): void => { + // A disposed world runs nothing more: its observers' first values never come. +}; + +/** Runs nothing: a run of it resolves while its world lives. */ +const alive = System.create({ name: ownName("Observe/alive"), observer: true }, () => {}); + +/** + * What a rejected run of the factories' own does: nothing for a disposed world, whose first values never come, and for + * a live one, such as one whose system failed to register, rethrows the rejection out of band. + */ +const rejectedOf = (world: World) => (error: unknown): void => { + world.run(alive).then(() => rethrowLater(error), ignore); +}; + +/** + * A run of the factories' own, whose report nothing reads: its failures of their systems, and its rejection, are + * rethrown out of band. + */ +const runOwn = (state: WorldState, system: System): void => { + let composite = state.composites.get(system); + if (composite === undefined) state.composites.set(system, composite = System.create([system, state.rethrow])); + state.world.run(composite).then(undefined, state.rejected); +}; + +const noTasks: readonly (() => void)[] = []; + +/** + * A run of a joined thread's shape for first values: once it resolved, a post for it ran there, so `read` gives them. + * What its system threw is in a later report of the world's. + */ +const runJoined = (state: WorldState, read: Read, batch: number): void => { + state.world.run(read.system).then(() => read.delivered(batch), state.rejected); +}; + +/** Runs what subscribers waiting for the open flush need, in call order after every run called before them. */ +const flush = (state: WorldState): void => { + state.scheduled = false; + const batch = state.open++; + const { reads, world } = state; + if (state.joined) { + for (let i = 0; i < reads.length; i++) runJoined(state, reads[i], batch); + reads.length = 0; + return; + } + for (let i = 0; i < reads.length; i++) { + reads[i].flushed(batch); + runOwn(state, reads[i].system); + } + reads.length = 0; + if (!state.delivering) return; + state.delivering = false; + state.deliveries.push({ batch, tasks: state.tasks.length === 0 ? noTasks : state.tasks }); + if (state.tasks.length > 0) state.tasks = []; + runOwn(state, state.deliver); +}; + +/** + * Removes the shapes found idle in three passes: one used in a pass, before or after this system ran in it, stays + * through the next two, so loops reading first values between runs stay warm. + */ +const sweepShapes = (state: WorldState): void => { + const { idle } = state; + let kept = 0; + for (let i = 0; i < idle.length; i++) { + const shape = idle[i]; + if (shape.live > 0) continue; + if (++shape.idle > 2) { + shape.remove(); + continue; + } + idle[kept++] = shape; + } + idle.length = kept; +}; + +/** A system no world adds, which only a joined thread's `remove` refuses, since it isn't an observer. */ +const probe = System.create({ name: "Observe/probe" }, () => {}); + +/** Whether `world` is a joined thread's: its `remove` takes only observer systems, where the world's ignores one never added. */ +const isJoined = (world: World): boolean => { + try { + world.remove(probe); + return false; + } + catch { + return true; + } +}; + +/** + * On a joined thread, counts a pass once per turn of its event loop that ran a shape's system, and sweeps idle shapes + * after the turn's posts ran, for no system orders passes there. + */ +export const passed = (state: WorldState): void => { + if (!state.joined || state.counted) return; + state.counted = true; + state.pass++; + queueMicrotask(state.endTurn); +}; + +export const stateOf = (world: World): WorldState => { + let state = states.get(world); + if (state !== undefined) return state; + const joined = isJoined(world); + const created: WorldState = { + world, + joined, + counted: false, + endTurn: () => { + created.counted = false; + if (created.idle.length > 0) sweepShapes(created); + }, + pass: 0, + open: 1, + delivered: 0, + scheduled: false, + reads: [], + delivering: false, + tasks: [], + deliveries: [], + idle: [], + rejected: rejectedOf(world), + // Run with the factories' own systems, never added: what one of them throws is in a later report, whose errors + // are written once, which the next of the factories' runs sees. + rethrow: System.create({ name: ownName("Observe/rethrow"), observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + for (const failure of row[Report.errors].failures) if (ownNames.has(failure.name)) rethrowLater(new Error(`${failure.name}: ${failure.message}`)); + }), + composites: new WeakMap(), + deliver: System.create({ name: ownName("Observe/deliver"), observer: true }, () => { + const delivery = created.deliveries.shift(); + if (delivery === undefined) return; + created.delivered = delivery.batch; + for (let i = 0; i < delivery.tasks.length; i++) delivery.tasks[i](); + }), + }; + state = created; + states.set(world, created); + if (joined) return created; + // Added before any shape's system, so in each pass it counts the pass before they run. + world.add(System.create({ name: ownName("Observe/maintain"), observer: true }, () => { + created.pass++; + if (created.idle.length > 0) sweepShapes(created); + })); + return created; +}; + +const schedule = (state: WorldState): void => { + if (state.scheduled) return; + state.scheduled = true; + queueMicrotask(() => flush(state)); +}; + +/** Makes the next flush, at the end of this task, deliver first values to subscribers of shapes that read in its pass. */ +export const scheduleDelivery = (state: WorldState): void => { + state.delivering = true; + schedule(state); +}; + +/** A read's step that does nothing. */ +export const noDelivery = (): void => { + // A read on one kind of thread has nothing to do at the step the other's flush takes. +}; + +/** Makes the next flush run a read, once, before it delivers; on a joined thread, once, giving first values once it resolved. */ +export const scheduleRead = (state: WorldState, read: Read): void => { + if (!state.reads.includes(read)) state.reads.push(read); + schedule(state); +}; + +/** Runs `task` in the next flush's delivering run, before shapes' systems run in its pass. */ +export const onNextDelivery = (state: WorldState, task: () => void): void => { + scheduleDelivery(state); + state.tasks.push(task); +}; + +/** Within a flush, before it delivers: makes it deliver, running `task` in its delivering run. */ +export const deliverAlso = (state: WorldState, task: () => void): void => { + state.delivering = true; + state.tasks.push(task); +}; + +/** Marks a shape with no subscribers left, for removal once it's been idle for a pass. */ +export const becameIdle = (state: WorldState, shape: Shape): void => { + shape.idle = 0; + if (!state.idle.includes(shape)) state.idle.push(shape); +}; + +/** + * Subscribes `notify` to an entry: with a valid value, given it now, where a throw stops it and throws to the caller; + * otherwise waiting for the next flush, which `wait` schedules. Returns the function that stops it. + */ +export const subscribe = (state: WorldState, shape: Shape, entry: Entry, notify: (value: T) => void, wait: () => void): (() => void) => { + // Before a first value given in a pass, its pass is one `give` never skips: not -1, which skips none. + const subscriber: Subscriber = { notify, batch: 0, pass: -2 }; + entry.subscribers.push(subscriber); + entry.live++; + entry.idle = 0; + shape.live++; + shape.idle = 0; + let active = true; + const stop = (): void => { + if (!active) return; + active = false; + if (subscriber.notify !== null) { + subscriber.notify = null; + entry.stopped++; + } + entry.live--; + if (subscriber.batch !== 0) { + subscriber.batch = 0; + entry.waiting--; + } + if (--shape.live === 0) becameIdle(state, shape); + }; + if (entry.valid) { + try { + notify(entry.value); + } + catch (error) { + stop(); + throw error; + } + return stop; + } + subscriber.batch = state.open; + entry.waiting++; + wait(); + return stop; +}; + +/** Drops an entry's stopped subscribers. */ +export const sweepSubscribers = (entry: Entry): void => { + if (entry.stopped === 0) return; + const { subscribers } = entry; + let kept = 0; + for (let i = 0; i < subscribers.length; i++) if (subscribers[i].notify !== null) subscribers[kept++] = subscribers[i]; + subscribers.length = kept; + entry.stopped = 0; +}; + +/** No error, as `give` returns it. */ +export const none: unique symbol = Symbol("Observe/none"); + +/** + * Gives an entry's value to its subscribers: a first value to each waiting for a flush up to `through`, whose throw + * stops it and is rethrown out of band; and with `changed`, the value to the others but those that had their first in + * pass `fresh`, -1 for none. Subscribers added meanwhile wait for a later pass. Returns the first error a later value's + * notify threw, or `failed` when it's already one. + */ +export const give = (entry: Entry, through: number, changed: boolean, pass: number, fresh: number, failed: unknown): unknown => { + let error = failed; + const { subscribers } = entry; + const value = entry.value; + for (let i = 0, count = subscribers.length; i < count; i++) { + const subscriber = subscribers[i]; + const notify = subscriber.notify; + if (notify === null) continue; + if (subscriber.batch !== 0) { + if (subscriber.batch > through) continue; + subscriber.batch = 0; + subscriber.pass = pass; + entry.waiting--; + try { + notify(value); + } + catch (thrown) { + // As a joined thread's first values are, it's thrown where nothing can catch it. + if (subscriber.notify !== null) { + subscriber.notify = null; + entry.stopped++; + } + rethrowLater(thrown); + } + continue; + } + if (!changed || subscriber.pass === fresh) continue; + try { + notify(value); + } + catch (thrown) { + if (error === none) error = thrown; + } + } + return error; +}; + +/** On a joined thread, gives an entry's first values to subscribers waiting for a flush up to `through`, once it holds one. */ +export const giveFirst = (state: WorldState, entry: Entry, through: number): void => { + if (entry.waiting === 0 || !entry.valid) return; + give(entry, through, false, state.pass, -1, none); +}; + +/** Throws what a notify threw, so its system's run reports it. */ +export const throwFailed = (failed: unknown): void => { + if (failed !== none) throw failed; +}; + +const numbers = new Map(); + +/** A key's number in this program, for keys of shapes. */ +export const numberOf = (key: symbol): number => { + let number = numbers.get(key); + if (number === undefined) numbers.set(key, number = numbers.size + 1); + return number; +}; + +/** A stable key for a list of component keys. */ +export const keysId = (keys: readonly symbol[]): string => { + let id = ""; + for (let i = 0; i < keys.length; i++) id += `${numberOf(keys[i])},`; + return id; +}; + +/** A label for a list of keys, by their descriptions. */ +export const labelOf = (keys: readonly symbol[]): string => keys.map(key => key.description ?? "?").join(", "); diff --git a/packages/ecs/src/perftest/benchmark.ts b/packages/ecs/src/perftest/benchmark.ts new file mode 100644 index 000000000..90f7b1d4e --- /dev/null +++ b/packages/ecs/src/perftest/benchmark.ts @@ -0,0 +1,106 @@ +/** A benchmark and what it stands for, so its results read without its code. */ +export type Benchmark = { + /** Stable across runs: results are tracked by it. */ + readonly name: string; + /** What it measures, and what systems or rules cause it in an app. */ + readonly operation: string; + /** What its time is divided by, such as "matched rows" or "spawns". */ + readonly per: string; + /** How many of `per` it runs with. */ + readonly n: number; + /** The range of `per` this operation usually sees in apps. */ + readonly typical: readonly [number, number]; + /** The best time it could take. */ + readonly best: Best; + /** Milliseconds. */ + readonly run: () => Promise; +}; + +/** + * The fewest bytes per N an ideal implementation must move, in this ECS's storage model, and why. Its time is those + * bytes at the fastest copy bandwidth the environment measured: of one thread, of every worker, or of the GPU. + */ +export type Best = { readonly bytes: number; readonly basis: string; readonly on?: "threads" | "gpu" }; + +/** Copy bandwidths by working set: bytes read and written per copy, and bytes per nanosecond. */ +export type Curve = readonly (readonly [number, number])[]; + +/** Bandwidths an environment measured. */ +export type Bandwidth = { readonly thread: Curve; readonly threads?: Curve; readonly gpu?: number }; + +/** Bytes per array of each calibration copy: from within the caches to well past them. */ +export const calibrationSizes: readonly number[] = [1 << 18, 1 << 20, 1 << 22, 1 << 24, 1 << 26]; + +/** A line `scripts/perf.mjs` reads from a run's output. */ +export const resultLine = (benchmark: Benchmark, milliseconds: number): string => { + const { run: _, ...description } = benchmark; + return `perf-result ${JSON.stringify({ ...description, milliseconds })}`; +}; + +export const environmentLine = (environment: string, bandwidth: Bandwidth): string => + `perf-environment ${JSON.stringify({ environment, bandwidth })}`; + +/** One thread's copy bandwidth at each calibration size, from the fastest of repeated timed batches of copies. */ +export const copyBandwidth = (): Curve => calibrationSizes.map(size => { + const length = size / 8; + const from = new Float64Array(length).fill(1); + const to = new Float64Array(length); + const copies = Math.max(1, Math.round((1 << 26) / size)); + let best = Infinity; + for (let i = 0; i < 12; i++) { + const start = performance.now(); + for (let k = 0; k < copies; k++) to.set(from); + best = Math.min(best, (performance.now() - start) / copies); + } + return [2 * size, (2 * size) / (best * 1e6)]; +}); + +/** A calibration worker: `ask` posts a message and resolves with the worker's reply. */ +export type CalibrationWorker = { readonly ask: (message: unknown) => Promise; readonly terminate: () => void }; + +/** + * The script each calibration worker runs, over its scope's `onmessage` and `postMessage`. Every claim copies one piece + * of `size / pieces` bytes `copies` times, so a pass copies `size` bytes `copies` times. A worker's buffers hold its + * share of `size`, at least one piece, and it copies its pieces in turn. + */ +export const calibrationSource = `onmessage = ({ data: [piece, share, counter, total, copies] }) => { + const length = piece / 8, count = Math.max(1, Math.round(share / piece)); + const source = new Float64Array(length * count).fill(1), target = new Float64Array(length * count), claims = new Int32Array(counter); + let next = 0; + postMessage(0); + onmessage = () => { + for (let claim = Atomics.add(claims, 0, 1); claim < total; claim = Atomics.add(claims, 0, 1)) { + const start = next * length, from = source.subarray(start, start + length); + for (let k = 0; k < copies; k++) target.set(from, start); + next = (next + 1) % count; + } + postMessage(0); + }; +};`; + +/** + * Every worker's copy bandwidth together at each calibration size, each of `threads` workers copying its share in + * buffers of its own, since copies between shared buffers are slower than the kernels' loads and stores. Workers + * claim pieces from a shared counter, as the ECS splits work, so the slowest core doesn't set the time. + */ +export const threadsBandwidth = async (threads: number, start: () => CalibrationWorker): Promise => { + const pieces = 64; + const curve: [number, number][] = []; + for (const size of calibrationSizes) { + const workers = Array.from({ length: threads }, start); + const counter = new Int32Array(new SharedArrayBuffer(4)); + const copies = Math.max(1, Math.round((1 << 28) / size)); + const each = (message: unknown): Promise => Promise.all(workers.map(worker => worker.ask(message))); + await each([size / pieces, size / threads, counter.buffer, pieces, copies]); + let best = Infinity; + for (let i = 0; i < 8; i++) { + counter[0] = 0; + const begin = performance.now(); + await each(0); + best = Math.min(best, (performance.now() - begin) / copies); + } + for (const worker of workers) worker.terminate(); + curve.push([2 * size, (2 * size) / (best * 1e6)]); + } + return curve; +}; diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts new file mode 100644 index 000000000..9f938b114 --- /dev/null +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -0,0 +1,692 @@ +import type { Component } from "../interface/component/component.js"; +import type { Entity } from "../interface/entity/entity.js"; +import { World } from "../interface/world/world.js"; +import { enabled as flatEnabled, spawnFlat, spawnFlatDynamic, visible as flatVisible } from "./flat.js"; +import { enabled, fastest as particlesFastest, move, position as particlePosition, spawn, visible } from "./particles.js"; +import { forest, local as hierarchyLocal, nudge, parent as hierarchyParent, propagate, propagateChanged, reparent, sprout, turnover, under } from "./hierarchy.js"; +import * as structural from "./structural.js"; +import { chain, churn, emit, failing, fastest, follow, highest, hits, idle, idles, idleObserver, index, indexSum, observe, poke, pokeCluster, reduce, retag, seeded, small, smalls, spawnOne, tag, totalOf } from "./suite.js"; + +import type { Benchmark } from "./benchmark.js"; +import { loopTickIdle, loopTicks, observerIdles } from "./loop.js"; +import { main, placed, pooled } from "./placed.js"; +import { wake } from "./wake.js"; +import { remoteBenchmarks } from "./remote.js"; +import { logBenchmarks } from "./log.js"; +import { handoffBenchmarks } from "./handoff.js"; +import { finalizedBenchmarks } from "./finalized.js"; +import { tailBenchmarks } from "./tails.js"; +import { readBenchmarks } from "./reads.js"; +import { observedBenchmarks } from "./observed.js"; +import { systemBenchmarks } from "./systems.js"; +import { indexBenchmarks } from "./indexes.js"; +import { stringBenchmarks } from "./strings.js"; +import { structReplace, valueBenchmarks } from "./values.js"; +import { createWorld } from "../implementation/index.js"; +import { workers } from "../interface/resources/workers.js"; +// Last, so the bundle orders the modules the benchmarks share as it did before these imports. +import { System } from "../interface/system/system.js"; +import { Observe } from "../observe/index.js"; +import { entitiesWith } from "./observer.js"; + +/** A world whose kernels run on this thread, which the benchmarks without a placement measure; `workers` ones use the pool. */ +const inline = (): World => createWorld({ [workers]: 0 }); + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +/** The fastest run of `observe` right after `writer` changes rows. */ +const observed = async (world: World, writer: typeof poke | typeof pokeCluster): Promise => { + let frame = 0; + let best = Infinity; + for (let i = 0; i < 60; i++) { + await world.run(writer, { group: frame++ % 100 }); + const start = performance.now(); + await world.run(observe); + if (i >= 10) best = Math.min(best, performance.now() - start); + } + return best; +}; + +/** The fastest run of `measured` right after `before` runs, over `rounds` rounds after `warmup`. */ +const after = async (before: () => Promise, measured: () => Promise, rounds = 100, warmup = 20): Promise => { + let best = Infinity; + for (let i = 0; i < rounds; i++) { + await before(); + const start = performance.now(); + await measured(); + if (i >= warmup) best = Math.min(best, performance.now() - start); + } + return best; +}; + +/** Entities in each structural benchmark. */ +const structuralCount = 100_000; + +/** A world of `structuralCount` seeded entities, in one archetype or interleaved over four. */ +const structuralWorld = async (sources: 1 | 4): Promise => { + const world = inline(); + await world.run(structural.seed, { count: structuralCount, sources }); + return world; +}; + +/** The fastest run of `measured` over a seeded world, after `before` restores what the last one changed. */ +const structuralBest = async (sources: 1 | 4, before: (world: World) => Promise, measured: (world: World) => Promise, verify: (world: World) => Promise, name: string): Promise => { + const world = await structuralWorld(sources); + const best = await after(() => before(world), () => measured(world), 40, 8); + check(await verify(world), `${name}: wrong result`); + await world.dispose(); + return best; +}; + +/** How many entities have `required`, through the observer factories: only once nothing more is timed on `world`, which then keeps their system. */ +const countOf = (world: World, required: readonly Component.Key[]): Promise => Observe.toPromise(Observe.count(world, { required })); + +/** A forest of 100K chains of 10, filtered propagation run once, and its roots. */ +const filteredForest = async (): Promise<{ readonly world: World; readonly roots: readonly Entity[] }> => { + const world = inline(); + await world.run(forest, { roots: 100_000, depth: 10 }); + await world.run(propagateChanged); + return { world, roots: await entitiesWith(world, [hierarchyLocal], [hierarchyParent]) }; +}; + +/** Bytes an `up` row moves at best: its own value read, its result written, its parent's result read, and its parent's row. */ +const upRow = 28; +/** Bytes per row for a filtered run to find what changed: one 8-byte tick per 1024-row chunk. */ +const chunkScan = 8 / 1024; + +/** A run's fixed cost at best: it reads one cache line of its system's state. */ +const runBest = { bytes: 64, basis: "A run reads at least one cache line of its system's state." }; + +/** Benchmarks shared by vitest (`*.perf-test.ts`) and the bundled node runner (`run.ts`). */ +/** The pool's benchmarks node runs too, on `worker_threads`. */ +const pooledInNode = new Set(["move workers", "follow workers", "emit workers", "spawn bulk kernel workers", "sync point grow 100 archetypes workers"]); + +export const benchmarks: readonly Benchmark[] = [ + { + name: "run idle", + operation: "Running one system no entity matches, in a world of 10K entities: the fixed cost of every system in every frame.", + per: "runs", + n: 1, + typical: [1, 1], + best: runBest, + run: async () => { + const world = await seeded(() => inline(), 10_000); + const best = await fastest(() => world.run(idle)); + await world.dispose(); + return best; + }, + }, + { + name: "run failing", + operation: "Running one system that throws, in a world of 10K entities: each run spawns its report, and once runs pass the reports kept, despawns the oldest, against `run idle`.", + per: "runs", + n: 1, + typical: [1, 1], + best: runBest, + run: async () => { + const world = await seeded(() => inline(), 10_000); + // Past the reports kept, so each timed run despawns one too, as a failing run does in steady state. + const best = await fastest(() => world.run(failing), 1000, 300); + await world.dispose(); + return best; + }, + }, + { + name: "run 50 idle", + operation: "Running fifty systems no entity matches, as one composite: the fixed cost per system of a frame of many systems.", + per: "systems", + n: 50, + typical: [10, 200], + best: runBest, + run: async () => { + const world = await seeded(() => inline(), 10_000); + const best = await fastest(() => world.run(idles)); + await world.dispose(); + return best; + }, + }, + { + name: "run observer", + operation: "Running one observer system no entity matches, in a world of 10K entities: a run with no tiers, its sync point empty, against `run idle`.", + per: "runs", + n: 1, + typical: [1, 1], + best: runBest, + run: async () => { + const world = await seeded(() => inline(), 10_000); + const best = await fastest(() => world.run(idleObserver)); + await world.dispose(); + return best; + }, + }, + { + name: "observer idle 100", + operation: "Ticks as in `loop tick idle`, with a hundred observer systems no entity matches added, each run after every tick: the cost per observer system per pass, a hundredth of the tick's own cost included.", + per: "observer systems", + n: loopTicks * observerIdles, + typical: [1, 100], + best: runBest, + run: () => loopTickIdle(5, observerIdles), + }, + { + name: "loop tick idle", + operation: "A world's loop ticking with nothing changed, driven back to back with `always` set, counted by an observer system reading its clock: the fixed cost of every tick.", + per: "ticks", + n: loopTicks, + typical: [1, 1], + best: runBest, + run: () => loopTickIdle(), + }, + { + name: "sync point one spawn", + operation: "Running a system that spawns one entity, so its run ends with a sync point that applies one command: the fixed cost of a structural change.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 128, basis: "The spawn writes its 28 bytes of values, its entity and its location, and the run reads its system's state." }, + run: async () => { + const world = await seeded(() => inline(), 10_000); + const best = await fastest(() => world.run(spawnOne)); + await world.dispose(); + return best; + }, + }, + ...[1_000, 10_000].map((rows): Benchmark => ({ + name: `move ${rows / 1_000}K`, + operation: "A kernel adding a vec3 to another for the rows with two tags, at a size a typical system sees.", + per: "matched rows", + n: rows, + typical: [1_000, 100_000], + best: { bytes: 36, basis: "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position." }, + run: async () => { + const world = inline(); + await world.run(spawn, { count: 4 * rows }); + const { minimum } = await particlesFastest(() => world.run(move)); + await world.dispose(); + return minimum; + }, + })), + ...[1_000, 10_000].map((rows): Benchmark => ({ + name: `main thread ${rows / 1_000}K`, + operation: "One main-thread system reading every row: app logic over a typical number of entities.", + per: "row reads", + n: rows, + typical: [1_000, 100_000], + best: { bytes: 12, basis: "Each row read loads its 12-byte position." }, + run: async () => { + const world = await seeded(() => inline(), rows); + const best = await fastest(() => world.run(small)); + check(totalOf() > 0, "main thread: nothing read"); + await world.dispose(); + return best; + }, + })), + { + name: "up depth 10 10K", + operation: "A kernel with `up` computing every row from its parent, over 1K chains of 10: a scene graph of typical size.", + per: "rows", + n: 10_000, + typical: [1_000, 100_000], + best: { bytes: upRow, basis: "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." }, + run: async () => { + const world = inline(); + await world.run(forest, { roots: 1_000, depth: 10 }); + const best = await fastest(() => world.run(propagate)); + await world.dispose(); + return best; + }, + }, + { + name: "create", + operation: "Spawning entities of six components, two of them tags, over four archetypes, through a main-thread system and the sync point that applies them: loading a level, a burst of particles.", + per: "spawns", + n: 1_000_000, + typical: [100, 100_000], + best: { bytes: 48, basis: "Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location." }, + run: () => fastest(async () => { + const world = inline(); + await world.run(spawn, { count: 1_000_000 }); + await world.dispose(); + }, 5000, 2), + }, + { + name: "create flat", + operation: "The same spawns with eight scalar components each, values passed inline.", + per: "spawns", + n: 1_000_000, + typical: [100, 100_000], + best: { bytes: 48, basis: "Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location." }, + run: () => { + // Checked once, while warming up, so the timed runs are the spawns alone. + let checked = false; + return fastest(async () => { + const world = inline(); + await world.run(spawnFlat, { count: 1_000_000 }); + if (!checked) check(await countOf(world, [flatVisible, flatEnabled]) === 250_000, "create flat: wrong count"); + checked = true; + await world.dispose(); + }, 5000, 2); + }, + }, + { + name: "create flat dynamic", + operation: "The same spawns from value objects built first, as spawns from data or from another system's results are.", + per: "spawns", + n: 1_000_000, + typical: [100, 100_000], + best: { bytes: 48, basis: "Each entity writes its 32 bytes of values, its 8-byte entity and its 8-byte location." }, + run: () => { + let checked = false; + return fastest(async () => { + const world = inline(); + await world.run(spawnFlatDynamic, { count: 1_000_000 }); + if (!checked) check(await countOf(world, [flatVisible, flatEnabled]) === 250_000, "create flat dynamic: wrong count"); + checked = true; + await world.dispose(); + }, 5000, 2); + }, + }, + { + name: "move", + operation: "A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update.", + per: "matched rows", + n: 250_000, + typical: [1_000, 1_000_000], + best: { bytes: 36, basis: "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position." }, + run: async () => { + const world = inline(); + await world.run(spawn, { count: 1_000_000 }); + check((await entitiesWith(world, [particlePosition, visible, enabled])).length === 250_000, "move: wrong count"); + const { minimum } = await particlesFastest(() => world.run(move)); + await world.dispose(); + return minimum; + }, + }, + { + name: "churn", + operation: "A main-thread system despawning a tenth of its rows and spawning as many: projectiles, particles, pooled UI.", + per: "rows visited", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 29, basis: "Each row reads 16 bytes; each tenth swaps the last row into its hole, 88 bytes, and spawns one of 44." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + let frame = 0; + const best = await fastest(() => world.run(churn, { group: frame++ % 10 })); + check(await countOf(world, [index]) === 1_000_000, "churn: wrong count"); + await world.dispose(); + return best; + }, + }, + { + name: "retag", + operation: "A main-thread system adding a tag to a tenth of its rows and removing it from another tenth: state flags such as selected or visible that move entities between archetypes.", + per: "rows visited", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 39, basis: "Each row reads 4 bytes; each fifth moves, copying its 44 bytes and swapping the last row into its hole." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + let frame = 0; + const best = await fastest(() => world.run(retag, { group: frame++ % 10 })); + check(await countOf(world, [tag]) > 0, "retag: no tags"); + await world.dispose(); + return best; + }, + }, + { + name: "sparse changed", + operation: "A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved.", + per: "rows in the archetype", + n: 1_000_000, + typical: [10_000, 1_000_000], + best: { bytes: 8.24, basis: "Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + const best = await observed(world, poke); + await world.dispose(); + return best; + }, + }, + { + name: "sparse changed clustered", + operation: "The same with the 1% in one contiguous run of rows, as edits to recently spawned entities are.", + per: "rows in the archetype", + n: 1_000_000, + typical: [10_000, 1_000_000], + best: { bytes: 0.33, basis: "Chunk ticks are scanned; the changed 1% read their 8-byte ticks and 24 bytes of values." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + const best = await observed(world, pokeCluster); + await world.dispose(); + return best; + }, + }, + { + name: "follow", + operation: "A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments.", + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 52, basis: "Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + const best = await fastest(() => world.run(follow)); + await world.dispose(); + return best; + }, + }, + { + name: "emit", + operation: "A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass.", + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 34, basis: "Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + const best = await fastest(() => world.run(emit)); + check(await countOf(world, [hits]) === 100_000, "emit: wrong count"); + await world.dispose(); + return best; + }, + }, + { + name: "reduce", + operation: "A kernel folding every row into a sum and a maximum: totals, bounds, counts.", + per: "rows", + n: 1_000_000, + typical: [1_000, 1_000_000], + best: { bytes: 4, basis: "Each row reads its 4-byte value." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + const best = await fastest(() => world.run(reduce)); + check(await Observe.toPromise(Observe.resource(world, highest)) === 999_999 && await Observe.toPromise(Observe.resource(world, indexSum)) === 999_999 * 500_000, "reduce: wrong fold"); + await world.dispose(); + return best; + }, + }, + { + name: "chain", + operation: "Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps.", + per: "row passes", + n: 6_000_000, + typical: [6_000, 6_000_000], + best: { bytes: 4, basis: "Fused into one pass, each row reads and writes its 12-byte velocity once for all six." }, + run: async () => { + const world = await seeded(() => inline(), 1_000_000); + const best = await fastest(() => world.run(chain)); + await world.dispose(); + return best; + }, + }, + ...[[100_000, 10], [1_000, 1_000]].map(([roots, depth]): Benchmark => ({ + name: `up depth ${depth}`, + operation: `A kernel with \`up\` computing every row from its parent, over ${roots === 1_000 ? "1K chains of 1,000" : "100K chains of 10"}: transforms, inherited visibility or style, recomputed in full.`, + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: upRow, basis: "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." }, + run: async () => { + const world = inline(); + await world.run(forest, { roots, depth }); + const best = await fastest(() => world.run(propagate)); + await world.dispose(); + return best; + }, + })), + { + name: "up one root changed", + operation: "A filtered kernel with `up` after one root's value changed: moving one object in a scene graph.", + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: chunkScan, basis: "Chunk ticks are scanned; the 10 rows of the changed chain run." }, + run: async () => { + const { world, roots } = await filteredForest(); + const best = await after(() => world.run(nudge, { entity: roots[0] }), () => world.run(propagateChanged)); + await world.dispose(); + return best; + }, + }, + { + name: "up after a spawn", + operation: "A kernel with `up` computing every row, after a spawn into its tree: a node added to a scene graph each frame.", + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: upRow, basis: "The run itself, with the order kept current at O(1) for the spawn." }, + run: async () => { + const world = inline(); + await world.run(forest, { roots: 100_000, depth: 10 }); + const roots = await entitiesWith(world, [hierarchyLocal], [hierarchyParent]); + const best = await after(() => world.run(sprout, { entity: roots[0] }), () => world.run(propagate)); + await world.dispose(); + return best; + }, + }, + ...[false, true].map((tagged): Benchmark => ({ + name: tagged ? "up filtered after tagged churn" : "up filtered after churn", + operation: `A filtered kernel with \`up\` after one root changed and 100 leaves were despawned and 100 spawned${tagged ? ", the leaves in an archetype of their own" : " in the tree's archetype"}: a scene graph with ${tagged ? "short-lived children of their own kind, such as effects" : "objects coming and going"}.`, + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: chunkScan + (210 * upRow) / 1_000_000, basis: "Chunk ticks are scanned; the changed chain and the 200 leaves run; the order is patched for 200 rows." }, + run: async () => { + const { world, roots } = await filteredForest(); + under.splice(0, under.length, ...roots.slice(1, 101)); + const best = await after(async () => { + await world.run(turnover, { count: 100, tagged }); + await world.run(nudge, { entity: roots[0] }); + }, () => world.run(propagateChanged)); + await world.dispose(); + return best; + }, + })), + { + name: "up filtered after a reparent", + operation: "A filtered kernel with `up` after a subtree of 9 rows moved to another parent: dragging an object to a new group.", + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: chunkScan, basis: "Chunk ticks are scanned; the 9 moved rows are renumbered and run." }, + run: async () => { + const { world, roots } = await filteredForest(); + const found: { child: Entity | null } = { child: null }; + await world.run(System.create({ observer: true, components: { read: [hierarchyParent] } }, row => { + if (found.child === null && row[hierarchyParent] === roots[0]) found.child = row.entity; + })); + const { child } = found; + if (child === null) throw new Error("up filtered after a reparent: no child"); + let frame = 0; + const best = await after(() => world.run(reparent, { entity: child, to: roots[1 + (frame++ % 2)] }), () => world.run(propagateChanged)); + await world.dispose(); + return best; + }, + }, + { + name: "up load", + operation: "Spawning a forest of 100K chains of 10 and running a kernel with `up` over it once: loading a scene.", + per: "spawns", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 40 + upRow, basis: "Each entity writes 24 bytes of values, its entity and its location, then runs once." }, + run: async () => { + let best = Infinity; + for (let i = 0; i < 5; i++) { + const world = inline(); + const start = performance.now(); + await world.run(forest, { roots: 100_000, depth: 10 }); + await world.run(propagate); + best = Math.min(best, performance.now() - start); + await world.dispose(); + } + return best; + }, + }, + { + name: "up small tree in a big world", + operation: "A kernel with `up` over a tree of 1K rows after a spawn into it, in a world of 1M other entities: a UI or a rig beside a large simulation.", + per: "tree rows", + n: 1_000, + typical: [100, 10_000], + best: { bytes: upRow, basis: "The run over the tree, with the order kept current at O(1) for the spawn." }, + run: async () => { + const world = inline(); + await world.run(spawn, { count: 1_000_000 }); + await world.run(forest, { roots: 100, depth: 10 }); + const roots = await entitiesWith(world, [hierarchyLocal], [hierarchyParent]); + const best = await after(() => world.run(sprout, { entity: roots[0] }), () => world.run(propagate)); + await world.dispose(); + return best; + }, + }, + { + name: "main thread", + operation: "Fifty main-thread systems each reading every row of 1K: many small app systems.", + per: "row reads", + n: 50_000, + typical: [1_000, 100_000], + best: { bytes: 12, basis: "Each row read loads its 12-byte position." }, + run: async () => { + const world = await seeded(() => inline(), 1_000); + const best = await fastest(() => world.run(smalls)); + check(totalOf() > 0, "main thread: nothing read"); + await world.dispose(); + return best; + }, + }, + { + name: "spawn bulk", + operation: "A main-thread system spawning 100K entities of one archetype, applied by its sync point: loading many of one kind.", + per: "spawns", + n: structuralCount, + typical: [100, 100_000], + best: { bytes: 24, basis: "Each entity writes its 8 bytes of values, its 8-byte entity and its 8-byte location." }, + run: () => structuralBest(1, world => world.run(structural.despawnSpawned), world => world.run(structural.spawnBulk, { count: structuralCount }), + async world => await countOf(world, [structural.spawned]) === structuralCount, "spawn bulk"), + }, + { + name: "spawn bulk kernel", + operation: "A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits.", + per: "spawns", + n: structuralCount, + typical: [100, 100_000], + best: { bytes: 28, basis: "Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location." }, + run: () => structuralBest(1, world => world.run(structural.despawnSpawned), world => world.run(structural.spawnKernel), + async world => await countOf(world, [structural.spawned]) === structuralCount, "spawn bulk kernel"), + }, + { + name: "spawn singles", + operation: "A run spawning one entity of each of four archetypes: the fixed cost of a few spawns a frame, such as a click or a message.", + per: "spawns", + n: 4, + typical: [1, 10], + best: { bytes: 64 / 4 + 24, basis: "Each entity writes its values, entity and location, and the run reads a cache line of its system's state." }, + run: async () => { + const world = await structuralWorld(1); + const best = await fastest(() => world.run(structural.spawnSingles)); + check(await countOf(world, [structural.spawned]) > 0, "spawn singles: nothing spawned"); + await world.dispose(); + return best; + }, + }, + { + name: "spawn sixteen archetypes", + operation: "Spawns from value objects over sixteen archetypes, more component sets than spawns cache: entities of many kinds, from data.", + per: "spawns", + n: structuralCount, + typical: [100, 100_000], + best: { bytes: 24, basis: "Each entity writes its 8 bytes of values, its 8-byte entity and its 8-byte location." }, + run: () => structuralBest(1, world => world.run(structural.despawnSpawned), world => world.run(structural.spawnSixteen, { count: structuralCount }), + async world => await countOf(world, [structural.spawned]) === structuralCount, "spawn sixteen archetypes"), + }, + { + name: "despawn bulk", + operation: "A main-thread system despawning 100K entities over four archetypes: unloading a level.", + per: "despawns", + n: structuralCount, + typical: [100, 100_000], + best: { bytes: 16, basis: "Each despawn frees its 8-byte location and entity; the archetypes empty, so no row moves." }, + run: () => structuralBest(4, world => world.run(structural.seed, { count: structuralCount, sources: 4 }), world => world.run(structural.despawnAll), + async world => await countOf(world, [structural.index]) === 0, "despawn bulk"), + }, + { + name: "despawn bulk kernel", + operation: "The same despawns from a kernel.", + per: "despawns", + n: structuralCount, + typical: [100, 100_000], + best: { bytes: 20, basis: "Each row reads its 4-byte index; each despawn frees its location and entity." }, + run: () => structuralBest(4, world => world.run(structural.seed, { count: structuralCount, sources: 4 }), world => world.run(structural.despawnKernel), + async world => await countOf(world, [structural.index]) === 0, "despawn bulk kernel"), + }, + { + name: "despawn one", + operation: "A run despawning one entity of 100K: the fixed cost of a despawn, such as one projectile hitting.", + per: "despawns", + n: 1, + typical: [1, 10], + best: { bytes: 64 + 32, basis: "The despawn swaps the last row into its hole and frees its location, and the run reads its system's state." }, + run: async () => { + const world = await structuralWorld(1); + const best = await after(() => world.run(structural.spawnOne), () => world.run(structural.despawnOne), 2_000, 200); + check(await countOf(world, [structural.spawned]) === 0, "despawn one: entities left"); + await world.dispose(); + return best; + }, + }, + ...([ + ["add tag", "Adding a tag to 100K entities of one archetype: a state flag set on many at once.", 1, structural.removeFlag, structural.addFlag, [structural.flag], 16], + ["add tag kernel", "The same adds from a kernel.", 1, structural.removeFlagKernel, structural.addFlagKernel, [structural.flag], 20], + ["add tag from four archetypes", "Adding a tag to 100K entities over four archetypes, each moving to its own target.", 4, structural.removeFlag, structural.addFlag, [structural.flag], 16], + ["add value", "Adding a valued component to 100K entities of one archetype: attaching data such as a timer or a score.", 1, structural.removeAmount, structural.addAmount, [structural.amount], 20], + ["add several", "Adding a tag and two values at once to 100K entities: attaching a bundle of components.", 1, structural.removeSeveral, structural.addSeveral, [structural.flag, structural.amount, structural.extra], 24], + ["remove tag", "Removing a tag from 100K entities of one archetype.", 1, structural.addFlag, structural.removeFlag, null, 16], + ["remove tag kernel", "The same removes from a kernel.", 1, structural.addFlagKernel, structural.removeFlagKernel, null, 20], + ["remove tag from four archetypes", "Removing a tag from 100K entities over four archetypes.", 4, structural.addFlag, structural.removeFlag, null, 16], + ["remove several", "Removing a tag and two values at once from 100K entities.", 1, structural.addSeveral, structural.removeSeveral, null, 16], + ] as const).map(([name, operation, sources, before, measured, added, bytes]): Benchmark => ({ + name, + operation, + per: "entities moved", + n: structuralCount, + typical: [100, 100_000], + best: { bytes, basis: "Each entity copies its row to the target archetype and its location changes; the source empties, so no row swaps into a hole." }, + run: () => structuralBest(sources, world => world.run(before), world => world.run(measured), + async world => added === null ? await countOf(world, [structural.flag]) === 0 : await countOf(world, added) === structuralCount, name), + })), + { + name: "add replace", + operation: "Adding a value 100K entities already have: values replaced and marked changed, with no archetype moves.", + per: "entities", + n: structuralCount, + typical: [100, 100_000], + best: { bytes: 12, basis: "Each row reads its 4-byte value and writes 4 bytes, plus its 4-byte location read." }, + run: async () => { + const world = await structuralWorld(1); + await world.run(structural.addAmount); + const best = await fastest(() => world.run(structural.replaceAmount)); + check(await countOf(world, [structural.amount]) === structuralCount, "add replace: wrong count"); + await world.dispose(); + return best; + }, + }, + ...placed([main]).filter(benchmark => benchmark.name === "emit main"), + ...placed([pooled]).filter(benchmark => pooledInNode.has(benchmark.name)), + wake, + ...remoteBenchmarks, + ...logBenchmarks, + ...handoffBenchmarks, + ...finalizedBenchmarks, + ...tailBenchmarks, + ...readBenchmarks, + ...observedBenchmarks, + ...systemBenchmarks, + ...indexBenchmarks, + ...stringBenchmarks, + ...valueBenchmarks, + structReplace("main", 0), +]; diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts new file mode 100644 index 000000000..68479c044 --- /dev/null +++ b/packages/ecs/src/perftest/finalized.ts @@ -0,0 +1,122 @@ +/// +import { Component } from "../interface/component/component.js"; +import { Finalizer } from "../interface/finalizer/finalizer.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { runReported } from "../implementation/world/reports.test-helper.js"; + +// Despawning entities whose objects a joined thread fills, with a finalizer on that thread: the world's thread writes +// each stop's declared struct reads into the thread's stop list and doesn't wait for it. The joined thread is +// `joined.ts`, which imports this module, so it runs the finalizer. + +const spot = Symbol("finalized/spot"); +const handle = Symbol("finalized/handle"); +declare module "@adobe/ecs" { + export interface Threads { + finalizedBenchmark: true; + } + export interface Components { + [spot]: { type: readonly [number, number, number] }; + [handle]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; + } +} +Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); +/** An object of the joined thread, as a DOM node or a GPU handle is. */ +Component.register(handle, { thread: "finalizedBenchmark" }); + +/** Entities each timed sync point despawns. */ +export const despawnedObjects = 10_000; + +/** On the joined thread: what the finalizer released. */ +export const releases = { count: 0 }; +export const release = Finalizer.create({ name: "finalized/release", components: { read: [handle, spot] } }, row => { + row[handle]?.release(); + if (row[spot][0] >= 0) releases.count++; +}); + +const spawn = System.create({ name: "finalized/spawn" }, ({ commands }) => { + for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, handle])({ [spot]: [k, 0, 0], [handle]: null }); +}); +const despawn = System.create({ name: "finalized/despawn", components: { has: [handle, spot] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); + +const joinedFile = "./joined.js"; + +/** Starts `joined.ts` on a worker, joined to `world` and claiming the benchmark's thread, once it joined. */ +const startClaiming = async (world: CreatedWorld): Promise<{ readonly terminate: () => Promise }> => { + const message = (port: unknown) => ({ kind: "port", port, threads: ["finalizedBenchmark"] }); + let terminate: () => Promise; + if (typeof globalThis.Worker === "function") { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = new globalThis.Worker(new URL("./joined.js", import.meta.url), { type: "module" }); + await new Promise(resolve => { + worker.onmessage = resolve; + worker.postMessage(message(port2), [port2]); + }); + terminate = async () => worker.terminate(); + } + else { + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const { port1, port2 } = new threads.MessageChannel(); + world.share(port1); + const worker = new threads.Worker(new URL(joinedFile, import.meta.url)); + await new Promise(resolve => { + worker.once("message", resolve); + worker.postMessage(message(port2), [port2]); + }); + terminate = async () => { await worker.terminate(); }; + } + await new Promise(resolve => { + let done = false; + const stop = world.threads(threads => { + if (done || threads.at(-1)?.status !== "joined") return; + done = true; + queueMicrotask(() => stop()); + resolve(); + }); + }); + return { terminate }; +}; + +/** + * The fastest of `rounds` sync points despawning `despawnedObjects` entities holding the joined thread's objects, + * timed on the world's thread, each after a spawn and a pause in which the thread finalizes the last. + */ +const despawnObjects = async (rounds = 30): Promise => { + const world = createWorld({ [workers]: 0 }); + const joined = await startClaiming(world); + let best = Infinity; + try { + for (let round = 0; round < rounds; round++) { + await world.run(spawn); + const start = performance.now(); + const report = await runReported(world, despawn); + const time = performance.now() - start; + if (report.failures.length > 0) throw new Error(`despawn objects: ${report.failures[0].message}`); + if (round > 1) best = Math.min(best, time); + // The thread finalizes them and acknowledges, so the next sync point returns their indexes. + await new Promise(resolve => setTimeout(resolve, 5)); + } + } + finally { + await joined.terminate(); + await world.dispose(); + } + return best; +}; + +export const finalizedBenchmarks: readonly Benchmark[] = [ + { + name: "despawn objects 10K", + operation: "A sync point despawning 10K entities whose objects a joined worker fills, with a finalizer there reading the object and a vec3: releasing DOM nodes or GPU handles, which the world's thread doesn't wait for.", + per: "despawns", + n: despawnedObjects, + typical: [1, 10_000], + best: { bytes: 56, basis: "Each despawn frees its 8-byte location and entity, and writes its stop: its entity, archetype, row and tag, 20 bytes, and its 12-byte vec3, read and written." }, + run: () => despawnObjects(), + }, +]; diff --git a/packages/ecs/src/perftest/flat.ts b/packages/ecs/src/perftest/flat.ts new file mode 100644 index 000000000..1f06e4505 --- /dev/null +++ b/packages/ecs/src/perftest/flat.ts @@ -0,0 +1,67 @@ +import { Component } from "../interface/component/component.js"; +import { System } from "../interface/system/system.js"; +import { Archetype } from '../interface/archetype/archetype.js'; + +// Mirrors adobe/data's ecs2 `create` perftest (packages/data/src/perftest/ecs2-perf.ts): 1M particles of eight scalar +// components, interleaved over four archetypes by visible/enabled, each with its own values. + +const color = Symbol("flat/color"); +const mass = Symbol("flat/mass"); +const positionX = Symbol("flat/positionX"); +const positionY = Symbol("flat/positionY"); +const positionZ = Symbol("flat/positionZ"); +const velocityX = Symbol("flat/velocityX"); +const velocityY = Symbol("flat/velocityY"); +const velocityZ = Symbol("flat/velocityZ"); +export const visible = Symbol("flat/visible"); +export const enabled = Symbol("flat/enabled"); + +declare module "@adobe/ecs" { + export interface Components { + [color]: { type: number }; + [mass]: { type: number }; + [positionX]: { type: number }; + [positionY]: { type: number }; + [positionZ]: { type: number }; + [velocityX]: { type: number }; + [velocityY]: { type: number }; + [velocityZ]: { type: number }; + [visible]: { type: true }; + [enabled]: { type: true }; + } +} + +Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(mass, { type: { type: "number", precision: 1 } }); +Component.register(positionX, { type: { type: "number", precision: 1 } }); +Component.register(positionY, { type: { type: "number", precision: 1 } }); +Component.register(positionZ, { type: { type: "number", precision: 1 } }); +Component.register(velocityX, { type: { type: "number", precision: 1 } }); +Component.register(velocityY, { type: { type: "number", precision: 1 } }); +Component.register(velocityZ, { type: { type: "number", precision: 1 } }); +Component.register(visible, { type: { const: true } }); +Component.register(enabled, { type: { const: true } }); + +const scalars = [color, mass, positionX, positionY, positionZ, velocityX, velocityY, velocityZ] as const; +/** Entity i goes to archetype i % 4, like ecs2's seedSingle. */ +const archetypes = [ + Archetype.create([...scalars, visible, enabled]), + Archetype.create([...scalars, visible]), + Archetype.create([...scalars, enabled]), + Archetype.create(scalars), +] as const; + +/** Spawns with values passed inline. */ +export const spawnFlat = System.create({ name: "flat/spawn", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) { + commands.spawn(archetypes[i % 4])({ [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }); + } +}); + +/** The same spawns with each value object built first, as values from data or from another system's results are. */ +export const spawnFlatDynamic = System.create({ name: "flat/dynamic", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) { + const values = { [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }; + commands.spawn(archetypes[i % 4])(values); + } +}); diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts new file mode 100644 index 000000000..f6c1e86bb --- /dev/null +++ b/packages/ecs/src/perftest/growth.ts @@ -0,0 +1,55 @@ +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import { System } from "../interface/system/system.js"; + +// A hundred archetypes, each grown at once: what a sync point costs where every archetype it touches grows. + +const value = Symbol("growth/value"); +const g0 = Symbol("growth/g0"); +const g1 = Symbol("growth/g1"); +const g2 = Symbol("growth/g2"); +const g3 = Symbol("growth/g3"); +const g4 = Symbol("growth/g4"); +const g5 = Symbol("growth/g5"); +const g6 = Symbol("growth/g6"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [g0]: { type: true }; + [g1]: { type: true }; + [g2]: { type: true }; + [g3]: { type: true }; + [g4]: { type: true }; + [g5]: { type: true }; + [g6]: { type: true }; + } +} + +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(g0, { type: { const: true } }); +Component.register(g1, { type: { const: true } }); +Component.register(g2, { type: { const: true } }); +Component.register(g3, { type: { const: true } }); +Component.register(g4, { type: { const: true } }); +Component.register(g5, { type: { const: true } }); +Component.register(g6, { type: { const: true } }); + +const tags = [g0, g1, g2, g3, g4, g5, g6] as const; +/** `value` with each of the first 100 sets of the seven tags, by their bits. */ +const hundred = Array.from({ length: 100 }, (_, bits) => Archetype.create([value, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); + +/** Spawns `count` entities into each of the hundred archetypes. */ +export const fillEach = System.create({ name: "growth/fillEach", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (const archetype of hundred) for (let i = 0; i < input.count; i++) commands.spawn(archetype)({ [value]: i }); +}); + +/** One spawn into each of the hundred archetypes. */ +export const spawnEach = System.create({ name: "growth/spawnEach" }, ({ commands }) => { + for (const archetype of hundred) commands.spawn(archetype)({ [value]: 0 }); +}); + +/** A kernel over every row of the hundred archetypes. */ +export const bumpEach = System.create({ name: "growth/bumpEach", kernel: "cpu", components: { write: [value] } }, row => { + row[value] = row[value] + 1; +}); diff --git a/packages/ecs/src/perftest/handoff.ts b/packages/ecs/src/perftest/handoff.ts new file mode 100644 index 000000000..80ee68bd0 --- /dev/null +++ b/packages/ecs/src/perftest/handoff.ts @@ -0,0 +1,154 @@ +/// +import { Resource } from "../interface/resource/resource.js"; +import { always } from "../interface/resources/always.js"; +import { eachTick } from "./observer.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { backToBack, createWorld, type CreatedWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { runReported } from "../implementation/world/reports.test-helper.js"; + +// Benchmarks of systems on a joined thread, which claims "handoffBenchmark": tiers handing off between the world's +// thread and the joined one, and consecutive tiers on the joined one, which run as one task. Their systems reach their +// commands, so they block their tiers rather than run as tails. The joined thread is `joined.ts`. + +export const token = Symbol("handoff/token"); +declare module "@adobe/ecs" { + export interface Threads { + handoffBenchmark: true; + } + export interface Resources { + [token]: { type: { readonly read: () => number } | null; thread: "handoffBenchmark" }; + } +} +/** An object of the joined thread, which places the systems reading it there. */ +Resource.register(token, { thread: "handoffBenchmark" }); + +const here0 = System.create({ name: "handoff/here0" }, () => {}); +const there0 = System.create({ name: "handoff/there0", resources: { read: [token] }, after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here1 = System.create({ name: "handoff/here1", after: [there0] }, () => {}); +const there1 = System.create({ name: "handoff/there1", resources: { read: [token] }, after: [here1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here2 = System.create({ name: "handoff/here2", after: [there1] }, () => {}); +const there2 = System.create({ name: "handoff/there2", resources: { read: [token] }, after: [here2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here3 = System.create({ name: "handoff/here3", after: [there2] }, () => {}); +const there3 = System.create({ name: "handoff/there3", resources: { read: [token] }, after: [here3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here4 = System.create({ name: "handoff/here4", after: [there3] }, () => {}); +const there4 = System.create({ name: "handoff/there4", resources: { read: [token] }, after: [here4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +/** Ten tiers alternating the world's thread and the joined one, empty systems. */ +export const handoff = System.create({ name: "handoff/ticks", systems: [here0, there0, here1, there1, here2, there2, here3, there3, here4, there4] }); + +const fused0 = System.create({ name: "handoff/fused0", resources: { read: [token] } }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused1 = System.create({ name: "handoff/fused1", resources: { read: [token] }, after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused2 = System.create({ name: "handoff/fused2", resources: { read: [token] }, after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused3 = System.create({ name: "handoff/fused3", resources: { read: [token] }, after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused4 = System.create({ name: "handoff/fused4", resources: { read: [token] }, after: [fused3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused5 = System.create({ name: "handoff/fused5", resources: { read: [token] }, after: [fused4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused6 = System.create({ name: "handoff/fused6", resources: { read: [token] }, after: [fused5] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused7 = System.create({ name: "handoff/fused7", resources: { read: [token] }, after: [fused6] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused8 = System.create({ name: "handoff/fused8", resources: { read: [token] }, after: [fused7] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused9 = System.create({ name: "handoff/fused9", resources: { read: [token] }, after: [fused8] }, ({ resources, commands }) => { void resources[token]; void commands; }); +/** Ten consecutive tiers on the joined thread, empty systems. */ +export const fused = System.create({ name: "handoff/fused", systems: [fused0, fused1, fused2, fused3, fused4, fused5, fused6, fused7, fused8, fused9] }); + +/** Every system the joined thread runs, which it imports so the world can place them there. */ +export const handoffSystems: readonly System[] = [handoff, fused]; + +/** Ticks in each timed stretch. */ +export const handoffTicks = 1_000; + +/** The joined thread, `joined.ts` on a worker, asked to join through a port and claim "handoffBenchmark". */ +type Joined = { readonly terminate: () => Promise }; + +const joinedFile = "./joined.js"; + +/** Starts `joined.ts` on a worker, joining `world` through a channel it's given and claiming the benchmark's thread. */ +const startClaiming = async (world: CreatedWorld): Promise => { + const message = (port: unknown) => ({ kind: "port", port, threads: ["handoffBenchmark"] }); + let joined: Joined; + if (typeof globalThis.Worker === "function") { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = new globalThis.Worker(new URL("./joined.js", import.meta.url), { type: "module" }); + await new Promise(resolve => { + worker.onmessage = resolve; + worker.postMessage(message(port2), [port2]); + }); + joined = { terminate: async () => worker.terminate() }; + } + else { + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const { port1, port2 } = new threads.MessageChannel(); + world.share(port1); + const worker = new threads.Worker(new URL(joinedFile, import.meta.url)); + await new Promise(resolve => { + worker.once("message", resolve); + worker.postMessage(message(port2), [port2]); + }); + joined = { terminate: async () => { await worker.terminate(); } }; + } + // Its claims are active once it joined, so the systems run there from the first tick. + await new Promise(resolve => { + let done = false; + const stop = world.threads(threads => { + if (done || threads.at(-1)?.status !== "joined") return; + done = true; + queueMicrotask(() => stop()); + resolve(); + }); + }); + return joined; +}; + +/** The fastest of `stretches` stretches of `handoffTicks` ticks of `system`, driven back to back, after one to warm up. */ +const ticksBest = async (system: System, stretches = 5): Promise => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const joined = await startClaiming(world); + let best = Infinity; + try { + await new Promise(resolve => { + let ticks = 0; + let stretch = 0; + let start = 0; + const unobserve = eachTick(world, () => { + if (ticks++ % handoffTicks !== 0) return; + const now = performance.now(); + if (stretch > 1) best = Math.min(best, now - start); + start = now; + if (stretch++ <= stretches) return; + unobserve(); + resolve(); + }); + world.add(system); + }); + const report = await runReported(world, system); + if (report.failures.length > 0 || report.warnings.length === 0) throw new Error(`handoff: the joined thread didn't run its systems: ${report.failures[0]?.message ?? "none placed there"}.`); + } + finally { + await joined.terminate(); + await world.dispose(); + driver.close(); + } + return best; +}; + +export const handoffBenchmarks: readonly Benchmark[] = [ + { + name: "handoff", + operation: "Ticks of ten tiers alternating the world's thread and a joined worker, empty systems: five tasks posted and waited for each tick, as when systems that write a worker's objects sit between the world's.", + per: "ticks", + n: handoffTicks, + typical: [1, 1], + best: { bytes: 5 * 256, basis: "Each of five handoffs moves at least the task's posted, done, payload and completion lines between cores." }, + run: () => ticksBest(handoff), + }, + { + name: "fused tiers", + operation: "Ticks of ten consecutive tiers on a joined worker, empty systems, which run as one task: a worker filling objects whose systems follow one another.", + per: "ticks", + n: handoffTicks, + typical: [1, 1], + best: { bytes: 256, basis: "One task moves at least its posted, done, payload and completion lines between cores." }, + run: () => ticksBest(fused), + }, +]; diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts new file mode 100644 index 000000000..ebe3d15dc --- /dev/null +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -0,0 +1,132 @@ +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import { Entity } from "../interface/entity/entity.js"; +import { System } from "../interface/system/system.js"; + +export const local = Symbol("hierarchy/local"); +export const world = Symbol("hierarchy/world"); +export const parent = Symbol("hierarchy/parent"); +export const leaf = Symbol("hierarchy/leaf"); + +declare module "@adobe/ecs" { + export interface Components { + [local]: { type: number }; + [world]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [leaf]: { type: true }; + } +} + +Component.register(local, { type: { type: "number" } }); +Component.register(world, { type: { type: "number" } }); +Component.register(parent, { type: Entity.schema, reference: "owner" }); +Component.register(leaf, { type: { const: true } }); + +const root = Archetype.create([local, world]); +const child = Archetype.create([local, world, parent]); +const taggedChild = Archetype.create([local, world, parent, leaf]); + +/** Each row's world value is its parent's plus its own. */ +export const propagate = System.create({ + name: "hierarchy/propagate", + kernel: "cpu", + components: { read: [local], write: [world] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + row[world] = (up === null ? 0 : up[world]) + row[local]; +}); + +/** The same, only for rows whose own or parent's values changed since it last ran. */ +export const propagateChanged = System.create({ + name: "hierarchy/propagateChanged", + kernel: "cpu", + components: { read: [local], write: [world], changed: [local] }, + up: { through: parent, read: [world] }, +}, (row, { up }) => { + row[world] = (up === null ? 0 : up[world]) + row[local]; +}); + +/** `roots` chains of `depth` rows each. */ +export const forest = System.create({ + name: "hierarchy/forest", + input: { type: "object", properties: { roots: { type: "integer" }, depth: { type: "integer" } } }, +}, ({ input, commands }) => { + for (let r = 0; r < input.roots; r++) { + let previous = commands.spawn(root)({ [local]: 1, [world]: 0 }); + for (let d = 1; d < input.depth; d++) previous = commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: previous }); + } +}); + +const target = { type: "object", properties: { entity: Entity.schema } } as const; + +/** Changes one row's own value. */ +export const nudge = System.create({ name: "hierarchy/nudge", input: target, components: { write: [local] } }, (row, { input }) => { + if (row.entity === input.entity) row[local] += 1; +}); + +/** Adds one leaf under a row. */ +export const sprout = System.create({ name: "hierarchy/sprout", input: target }, ({ input, commands }) => { + commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: input.entity }); +}); + +/** The rows `turnover` spawns leaves under, set by the benchmark. */ +export const under: Entity[] = []; +const leaves: Entity[] = []; + +/** Despawns the leaves it spawned last time and spawns `count` new ones, one under each of `under`'s first rows; `tagged` puts them in an archetype of their own. */ +export const turnover = System.create({ + name: "hierarchy/turnover", + input: { type: "object", properties: { count: { type: "integer" }, tagged: { type: "boolean" } } }, +}, ({ input, commands }) => { + for (const entity of leaves.splice(0)) commands.despawn(entity); + for (let i = 0; i < input.count; i++) { + leaves.push(input.tagged + ? commands.spawn(taggedChild)({ [local]: 1, [world]: 0, [parent]: under[i] }) + : commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: under[i] })); + } +}); + +/** Moves a row, and the subtree under it, to another parent. */ +export const reparent = System.create({ + name: "hierarchy/reparent", + input: { type: "object", properties: { entity: Entity.schema, to: Entity.schema } }, +}, ({ input, commands }) => { + commands.add(input.entity, parent, input.to); +}); + +export const offset = Symbol("hierarchy/offset"); +export const placed = Symbol("hierarchy/placed"); + +declare module "@adobe/ecs" { + export interface Components { + [offset]: { type: number }; + [placed]: { type: number }; + } +} + +Component.register(offset, { type: { type: "number", precision: 1 } }); +Component.register(placed, { type: { type: "number", precision: 1 } }); + +const gpuRoot = Archetype.create([offset, placed]); +const gpuChild = Archetype.create([offset, placed, parent]); + +/** `propagate` in 4-byte values, on the GPU. */ +export const place = System.create({ + name: "hierarchy/place", + kernel: "gpu", + components: { read: [offset], write: [placed] }, + up: { through: parent, read: [placed] }, +}, (row, { up }) => { + row[placed] = (up === null ? 0 : up[placed]) + row[offset]; +}); + +/** `forest` in 4-byte values. */ +export const gpuForest = System.create({ + name: "hierarchy/gpuForest", + input: { type: "object", properties: { roots: { type: "integer" }, depth: { type: "integer" } } }, +}, ({ input, commands }) => { + for (let r = 0; r < input.roots; r++) { + let previous = commands.spawn(gpuRoot)({ [offset]: 1, [placed]: 0 }); + for (let d = 1; d < input.depth; d++) previous = commands.spawn(gpuChild)({ [offset]: 1, [placed]: 0, [parent]: previous }); + } +}); diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts new file mode 100644 index 000000000..8a87551ea --- /dev/null +++ b/packages/ecs/src/perftest/indexes.ts @@ -0,0 +1,691 @@ +import { Component } from "../interface/component/component.js"; +import { Entity } from "../interface/entity/entity.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// Benchmarks of indexes: keeping them current, and looking up through them. + +const key = Symbol("indexes/key"); +const amount = Symbol("indexes/amount"); +const marked = Symbol("indexes/marked"); +const serial = Symbol("indexes/serial"); +const parent = Symbol("indexes/parent"); +const place = Symbol("indexes/place"); +const cell = Symbol("indexes/cell"); +declare module "@adobe/ecs" { + export interface Components { + [key]: { type: number }; + [amount]: { type: number }; + [marked]: { type: true }; + [serial]: { type: number }; + [parent]: { type: Entity }; + [place]: { type: readonly [number, number, number] }; + [cell]: { type: readonly [number, number, number] }; + } +} +Component.register(key, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); +Component.register(amount, { type: { type: "number" } }); +Component.register(marked, { type: { const: true } }); +Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); +Component.register(parent, { type: Entity.schema }); +Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); +Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -0x80000000, maximum: 0x7FFFFFFF }, minItems: 3, maxItems: 3 } }); + +const million = 1_000_000; + +/** Distinct keys among the indexed entities: ten entities a bucket. */ +const keys = million / 10; +const byKey = Index.create({ name: "indexes/byKey", key: [key] }); + +const seedKeys = System.create({ name: "indexes/seedKeys", input: { type: "object", properties: { count: { type: "integer" }, marked: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) { + if (i < input.marked) commands.spawn([key, amount, marked])({ [key]: (i * 7919) % keys, [amount]: i, [marked]: true }); + else commands.spawn([key, amount])({ [key]: (i * 7919) % keys, [amount]: i }); + } +}); + +/** Moves every marked entity to another bucket, and back again the next run. */ +let flip = 1; +const rekeyMarked = System.create({ name: "indexes/rekeyMarked", components: { write: [key], has: [marked] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); +/** Moves every entity to another bucket. */ +const rekeyAll = System.create({ name: "indexes/rekeyAll", components: { write: [key] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); + +let found = 0; +/** Uses `byKey`, so a run of it refreshes the index. */ +const lookOnce = System.create({ name: "indexes/lookOnce", lookup: { byKey: { index: byKey } } }, ({ lookup }) => { found += lookup.byKey(0).length; }); + +/** The fastest run of `measured` right after `before`. */ +const after = async (before: () => Promise, measured: () => Promise, rounds = 40, warmup = 6): Promise => { + let best = Infinity; + for (let i = 0; i < rounds; i++) { + await before(); + const start = performance.now(); + await measured(); + if (i >= warmup) best = Math.min(best, performance.now() - start); + } + return best; +}; + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +const inline = (): World => createWorld({ [workers]: 0 }); +/** Every hardware thread but the world's. */ +const threads = navigator.hardwareConcurrency - 1; +const pooled = (): World => createWorld({ [workers]: threads }); + +/** A kernel touching nothing, so a world's pool starts, as it does with its first kernel. */ +const startPool = System.create({ name: "indexes/startPool", kernel: "cpu", components: { has: [marked] } }, () => {}); + +/** Indexes `index idle` keeps, each over its own thousand entities. */ +const idleIndexes = 1000; +const idleKey = Symbol("indexes/idleKey"); +declare module "@adobe/ecs" { + export interface Components { + [idleKey]: { type: number }; + } +} +Component.register(idleKey, { type: { type: "number" } }); + +/** Unique serials among `unique lookup 1M`'s entities. */ +const bySerial = Index.create({ name: "indexes/bySerial", key: [serial], unique: true, components: { has: [amount] } }); +const seedSerials = System.create({ name: "indexes/seedSerials" }, ({ commands }) => { + for (let i = 0; i < million; i++) commands.spawn([serial, amount])({ [serial]: i, [amount]: 1 }); +}); +let summed = 0; +const lookUnique = System.create({ name: "indexes/lookUnique", lookup: { bySerial: { index: bySerial, read: [amount] } } }, ({ lookup }) => { + let sum = 0; + for (let i = 0; i < million; i++) sum += lookup.bySerial(i)?.[amount] ?? 0; + summed = sum; +}); + +/** Parents in `children lookup`, with ten children each. */ +const parents = 10_000; +const childrenOf = Index.create({ name: "indexes/childrenOf", key: [parent], components: { has: [amount] } }); +const seedFamilies = System.create({ name: "indexes/seedFamilies" }, ({ commands }) => { + for (let p = 0; p < parents; p++) { + const entity = commands.spawn([marked])({ [marked]: true }); + for (let c = 0; c < 10; c++) commands.spawn([parent, amount])({ [parent]: entity, [amount]: 1 }); + } +}); +const sumChildren = System.create({ name: "indexes/sumChildren", components: { has: [marked] }, lookup: { children: { index: childrenOf, read: [amount] } } }, (row, { lookup }) => { + const children = lookup.children(row.entity); + for (let i = 0; i < children.length; i++) summed += children.at(i)[amount]; +}); + +/** Particles in `grid neighbors 100K`, in a 100 by 100 by 10 grid of unit cells, ten a cell. */ +const particles = 100_000; +const grid = Index.create({ name: "indexes/grid", key: [cell], components: { has: [place] } }); +const seedParticles = System.create({ name: "indexes/seedParticles" }, ({ commands }) => { + for (let i = 0; i < particles; i++) { + const x = ((i * 7919) % 1000) / 10; + const y = ((i * 104729) % 1000) / 10; + const z = ((i * 1299709) % 100) / 10; + commands.spawn([place, cell])({ [place]: [x, y, z], [cell]: [Math.floor(x), Math.floor(y), Math.floor(z)] }); + } +}); +/** The same on a kernel: on the pool, each row's 27 probes in straight-line code. */ +const neighborsKernel = System.create({ name: "indexes/neighborsKernel", kernel: "cpu", components: { read: [place, cell], write: [amount] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { + const [cx, cy, cz] = row[cell]; + const [x] = row[place]; + let push = 0; + for (let dz = -1; dz <= 1; dz++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const near = lookup.near([cx + dx, cy + dy, cz + dz]); + for (let i = 0; i < near.length; i++) { + const [nx] = near.at(i)[place]; + push += nx - x; + } + } + } + } + row[amount] = push; +}); +const seedKernelParticles = System.create({ name: "indexes/seedKernelParticles" }, ({ commands }) => { + for (let i = 0; i < particles; i++) { + const x = ((i * 7919) % 1000) / 10; + const y = ((i * 104729) % 1000) / 10; + const z = ((i * 1299709) % 100) / 10; + commands.spawn([place, cell, amount])({ [place]: [x, y, z], [cell]: [Math.floor(x), Math.floor(y), Math.floor(z)], [amount]: 0 }); + } +}); + +/** Particles of `rekey 1M`, drifting through cells of 4 units. */ +const drifting = million; +const drift = System.create({ name: "indexes/drift", kernel: "cpu", components: { write: [place] } }, row => { + const [x, y, z] = row[place]; + row[place] = [x + 0.25, y, z]; +}); +/** The cell a particle is in, written only when it crosses into another, so the index's work is the crossings. */ +const rekeyCells = System.create({ name: "indexes/rekeyCells", kernel: "cpu", components: { read: [place], write: [cell], changed: [place] }, after: [drift] }, row => { + const [x, y, z] = row[place]; + const [cx, cy, cz] = row[cell]; + const nx = Math.floor(x / 4); + const ny = Math.floor(y / 4); + const nz = Math.floor(z / 4); + if (nx !== cx || ny !== cy || nz !== cz) row[cell] = [nx, ny, nz]; +}); +const coarse = Index.create({ name: "indexes/coarse", key: [cell], components: { has: [place] } }); +/** Uses `coarse` once, so a run refreshes it. */ +const lookCoarse = System.create({ name: "indexes/lookCoarse", lookup: { coarse: { index: coarse } }, after: [rekeyCells] }, ({ lookup }) => { found += lookup.coarse([0, 0, 0]).length; }); +const frame = System.create({ name: "indexes/frame", systems: [drift, rekeyCells, lookCoarse] }); +const seedDrifting = System.create({ name: "indexes/seedDrifting" }, ({ commands }) => { + for (let i = 0; i < drifting; i++) { + const x = (i * 7919) % 4000 / 10; + const y = (i * 104729) % 4000 / 10; + commands.spawn([place, cell])({ [place]: [x, y, 0], [cell]: [Math.floor(x / 4), Math.floor(y / 4), 0] }); + } +}); +/** The same frame without the index: what computing the key costs alone. */ +const frameUnindexed = System.create({ name: "indexes/frameUnindexed", systems: [drift, rekeyCells] }); + +/** Rows of `order run 100K`, ranked in a scattered order. */ +const orderedRows = 100_000; +const byRank = Index.create({ name: "indexes/byRank", key: [serial], sorted: true, components: { has: [amount] } }); +const seedRanked = System.create({ name: "indexes/seedRanked" }, ({ commands }) => { + for (let i = 0; i < orderedRows; i++) { + if (i < orderedRows / 100) commands.spawn([serial, amount, marked])({ [serial]: (i * 7919) % orderedRows, [amount]: i }); + else commands.spawn([serial, amount])({ [serial]: (i * 7919) % orderedRows, [amount]: i }); + } +}); +let accumulated = 0; +/** Sums its rows' amounts, weighting each by the sum so far, so the order rows run in is part of the result. */ +const inRankOrder = System.create({ name: "indexes/inRankOrder", components: { read: [amount] }, order: byRank }, row => { + accumulated = (accumulated * 31 + row[amount]) % 1000003; +}); +const inStorageOrder = System.create({ name: "indexes/inStorageOrder", components: { read: [amount] } }, row => { + accumulated = (accumulated * 31 + row[amount]) % 1000003; +}); +const changedInRankOrder = System.create({ name: "indexes/changedInRankOrder", components: { read: [amount], changed: [amount] }, order: byRank }, row => { + accumulated = (accumulated * 31 + row[amount]) % 1000003; +}); +const bumpMarked = System.create({ name: "indexes/bumpMarked", components: { write: [amount], has: [marked] } }, row => { row[amount] += 1; }); + +let pushed = 0; +const neighbors = System.create({ name: "indexes/neighbors", components: { read: [place, cell] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { + const [cx, cy, cz] = row[cell]; + const [x] = row[place]; + let push = 0; + for (let dz = -1; dz <= 1; dz++) for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { + const near = lookup.near([cx + dx, cy + dy, cz + dz]); + for (let i = 0; i < near.length; i++) push += near.at(i)[place][0] - x; + } + pushed += push; +}); + +/** Ranks of `sorted range 1M`, one an entity, and the ranges it looks up, ten ranks each. */ +const ranked = Index.create({ name: "indexes/ranked", key: [serial], sorted: true, components: { has: [amount] } }); +const ranges = 10_000; +const lookRanges = System.create({ name: "indexes/lookRanges", lookup: { ranked: { index: ranked, read: [amount] } } }, ({ lookup }) => { + let sum = 0; + for (let r = 0; r < ranges; r++) { + const start = (r * 7919) % (million - 10); + const range = lookup.ranked({ ">=": start, "<": start + 10 }); + for (let i = 0; i < range.length; i++) sum += range.at(i)[amount]; + } + summed = sum; +}); + +/** Entities `sorted page 100K` pages through, by a value one of them changes each run, the page the 20 after the first 100 with a small rank. */ +const pageRows = 100_000; +const byAmount = Index.create({ name: "indexes/byAmount", key: [amount], sorted: true, components: { has: [key] } }); +const seedPage = System.create({ name: "indexes/seedPage" }, ({ commands }) => { + commands.spawn([amount, key, marked])({ [amount]: 0, [key]: 0, [marked]: true }); + for (let i = 1; i < pageRows; i++) commands.spawn([amount, key])({ [amount]: (i * 7919) % pageRows, [key]: i }); +}); +const nudgeAmount = System.create({ name: "indexes/nudgeAmount", components: { has: [marked], write: [amount] } }, row => { row[amount] += 1; }); +let page: number[] = []; +/** The page, recomputed only when what it reads changed: the 20 rows after the first 100 with a key below half, highest amounts first. */ +const readPage = System.create({ name: "indexes/readPage", lookup: { byAmount: { index: byAmount, read: [key], changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { + const all = lookup.byAmount(); + if (!all.changed) return; + const rows: number[] = []; + let skipped = 0; + for (let i = all.length - 1; i >= 0 && rows.length < 20; i--) { + const row = all.at(i); + if (row[key] >= pageRows / 2) continue; + if (skipped++ < 100) continue; + rows.push(row.entity); + } + page = rows; +}); +const nudgeThenPage = System.create({ name: "indexes/nudgeThenPage", systems: [nudgeAmount, readPage] }); + +/** Entities of the keyless benchmarks, and the 1% of them `keyless refresh 1M 1%` moves in and out. */ +const tagged = Symbol("indexes/tagged"); +declare module "@adobe/ecs" { + export interface Components { + [tagged]: { type: true }; + } +} +Component.register(tagged, { type: { const: true } }); +const untagged = Index.create({ name: "indexes/untagged", key: [], components: { has: [amount], not: [tagged] } }); +const seedAmounts = System.create({ name: "indexes/seedAmounts" }, ({ commands }) => { + for (let i = 0; i < million; i++) { + if (i % 100 === 0) commands.spawn([amount, marked])({ [amount]: i, [marked]: true }); + else commands.spawn([amount])({ [amount]: i }); + } +}); +/** Tags every marked entity, which leaves the index, and untags them the next run, which enter it again. */ +let tagging = true; +const toggleMarked = System.create({ name: "indexes/toggleMarked", components: { has: [marked] } }, (row, { commands }) => { + if (tagging) commands.add(row.entity, tagged); + else commands.remove(row.entity, tagged); +}); +const lookUntagged = System.create({ name: "indexes/lookUntagged", lookup: { untagged: { index: untagged } } }, ({ lookup }) => { found += lookup.untagged().length; }); +/** Despawns the entities it spawned the run before and spawns as many again. */ +let spawnedLast: Entity[] = []; +const spawnAndDespawn = System.create({ name: "indexes/spawnAndDespawn" }, ({ commands }) => { + for (const entity of spawnedLast) commands.despawn(entity); + const spawned: Entity[] = []; + for (let i = 0; i < million / 100; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); + spawnedLast = spawned; +}); +/** Writes every entity's amount, which the index holds by but doesn't watch. */ +const writeAmounts = System.create({ name: "indexes/writeAmounts", kernel: "cpu", components: { write: [amount] } }, row => { row[amount] = row[amount] + 1; }); +/** Reads the index's length and whether it changed, as a count kept by a system would. */ +const lengthReads = 100_000; +const readLengths = System.create({ name: "indexes/readLengths", lookup: { untagged: { index: untagged } } }, ({ lookup }) => { + let sum = 0; + for (let i = 0; i < lengthReads; i++) { + const rows = lookup.untagged(); + sum += rows.changed ? 1 : rows.length; + } + found = sum; +}); + +/** Entities a flag sorts into two buckets, as a todo list's done does; and observer systems watching their counts. */ +const flagged = 10_000; +const watchers = 100; +const flag = Symbol("indexes/flag"); +declare module "@adobe/ecs" { + export interface Components { + [flag]: { type: boolean }; + } +} +Component.register(flag, { type: { type: "boolean" } }); +const byFlag = Index.create({ name: "indexes/byFlag", key: [flag] }); +const seedFlags = System.create({ name: "indexes/seedFlags" }, ({ commands }) => { + for (let i = 0; i < flagged; i++) commands.spawn([flag])({ [flag]: i % 2 === 0 }); +}); +/** A run of observer systems alone, which passes every one added. */ +const pass = System.create({ name: "indexes/pass", observer: true }, () => {}); +/** A count of each bucket, written only when the index's entries changed: by `changed: true`, or by its lists' `changed`. */ +const countsWhenChanged = (filtered: boolean): System => filtered + ? System.create({ observer: true, lookup: { byFlag: { index: byFlag, changed: true } } }, ({ lookup }) => { + found += lookup.byFlag(false).length + lookup.byFlag(true).length; + }) + : System.create({ observer: true, lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { + const off = lookup.byFlag(false); + const on = lookup.byFlag(true); + if (!off.changed && !on.changed) return; + found += off.length + on.length; + }); +/** The fastest pass of `watchers` observer systems of counts while the index rests. */ +const restingCounts = async (filtered: boolean): Promise => { + const world = inline(); + await world.run(seedFlags); + for (let k = 0; k < watchers; k++) world.add(countsWhenChanged(filtered)); + found = 0; + await world.run(pass); + check(found === watchers * flagged, "every observer system counts on its first pass"); + const best = await fastest(() => world.run(pass)); + check(found === watchers * flagged, "none counts again while the index rests"); + await world.dispose(); + return best; +}; + +export const indexBenchmarks: readonly Benchmark[] = [ + { + name: "observer index unchanged 100", + operation: "A pass of 100 observer systems, each writing two buckets' counts of a 10K-entity index only when its entries changed, by `changed: true`, while nothing changes: what such a binding costs each frame at rest, the pass's own cost included.", + per: "observer systems", + n: watchers, + typical: [1, 100], + best: { bytes: 8, basis: "Each observer system compares its index's changed tick, 8 bytes." }, + run: () => restingCounts(true), + }, + { + name: "observer index unchanged 100 by lists", + operation: "As `observer index unchanged 100`, each body looking up both buckets and returning when neither list changed: the saving `changed: true` makes.", + per: "observer systems", + n: watchers, + typical: [1, 100], + best: { bytes: 8, basis: "Each observer system compares its index's changed tick, 8 bytes." }, + run: () => restingCounts(false), + }, + { + name: "index idle", + operation: "A run of a system looking up through 1,000 indexes when nothing they hold changed: what keeping an index costs each frame while its entities rest.", + per: "indexes", + n: idleIndexes, + typical: [1, 100], + best: { bytes: 16, basis: "Each index reads its journal position and its key's last write, 8 bytes each." }, + run: async () => { + const world = inline(); + const indexes = Array.from({ length: idleIndexes }, () => Index.create({ key: [idleKey] })); + await world.run(System.create(({ commands }) => { for (let i = 0; i < 1000; i++) commands.spawn([idleKey])({ [idleKey]: i }); })); + const lookup = Object.fromEntries(indexes.map((index, k) => [`i${k}`, { index }])); + const user = System.create({ lookup }, () => {}); + const best = await fastest(() => world.run(user)); + await world.dispose(); + return best; + }, + }, + { + name: "keyless index idle", + operation: "A run of a system looking up through 1,000 indexes without a key when nothing they hold changed: what keeping a membership costs each frame while its entities rest.", + per: "indexes", + n: idleIndexes, + typical: [1, 100], + best: { bytes: 8, basis: "Each index reads its journal position, 8 bytes." }, + run: async () => { + const world = inline(); + const indexes = Array.from({ length: idleIndexes }, () => Index.create({ key: [], components: { has: [idleKey] } })); + await world.run(System.create(({ commands }) => { for (let i = 0; i < 1000; i++) commands.spawn([idleKey])({ [idleKey]: i }); })); + const lookup = Object.fromEntries(indexes.map((index, k) => [`i${k}`, { index }])); + const user = System.create({ lookup }, () => {}); + const best = await fastest(() => world.run(user)); + await world.dispose(); + return best; + }, + }, + { + name: "keyless refresh 1M 1%", + operation: "Refreshing an index without a key of 1M entities after 1% of them, spread through entity order, left it or entered it again at a sync point: a membership kept current as some entities gain or lose a tag each frame.", + per: "entities entering or leaving", + n: million / 100, + typical: [100, 100_000], + best: { bytes: 24, basis: "Each entity reads its 4-byte journal entry and its 8-byte location, and moves a 12-byte entry in or out." }, + run: async () => { + const world = inline(); + await world.run(seedAmounts); + await world.run(lookUntagged); + const best = await after(async () => { tagging = !tagging; await world.run(toggleMarked); }, () => world.run(lookUntagged)); + check(found > 0, "keyless refresh 1M 1%: nothing found"); + await world.dispose(); + return best; + }, + }, + { + name: "keyless spawn 1M 1%", + operation: "Refreshing an index without a key of 1M entities after a sync point despawned the 1% spawned the frame before and spawned as many again: a membership of entities that come and go in bursts, as particles do.", + per: "entities spawned or despawned", + n: million / 50, + typical: [100, 100_000], + best: { bytes: 24, basis: "Each entity reads its 4-byte journal entry and its 8-byte location, and moves a 12-byte entry in or out." }, + run: async () => { + const world = inline(); + await world.run(seedAmounts); + await world.run(lookUntagged); + const best = await after(() => world.run(spawnAndDespawn), () => world.run(lookUntagged)); + check(found > 0, "keyless spawn 1M 1%: nothing found"); + await world.dispose(); + return best; + }, + }, + { + name: "keyless write 1M", + operation: "Refreshing an index without a key of 1M entities after a kernel wrote a component every one of them has, which the index holds by but no lookup watches: a membership costs nothing for the values its entities write each frame.", + per: "rows written", + n: million, + typical: [10_000, 1_000_000], + best: { bytes: 0.001, basis: "Nothing it holds changed: the index reads its journal position and nothing per row." }, + run: async () => { + const world = inline(); + await world.run(seedAmounts); + await world.run(lookUntagged); + const best = await after(() => world.run(writeAmounts), () => world.run(lookUntagged)); + await world.dispose(); + return best; + }, + }, + { + name: "keyless length", + operation: "100K lookups through an index without a key of 1M entities, each reading its length and whether it changed, in a main-thread system: a count a system keeps.", + per: "lookups", + n: lengthReads, + typical: [1, 1_000], + best: { bytes: 16, basis: "Each lookup reads its length and its range's newest tick, 8 bytes each." }, + run: async () => { + const world = inline(); + await world.run(seedAmounts); + await world.run(readLengths); + const best = await fastest(() => world.run(readLengths), 2000, 3); + check(found > 0, "keyless length: nothing found"); + await world.dispose(); + return best; + }, + }, + { + name: "index refresh 1M 1%", + operation: "Refreshing a hash index of 1M entities after a system moved 1% of them to other keys: an index kept current as some entities change their key each frame.", + per: "keys changed", + n: million / 100, + typical: [100, 100_000], + best: { bytes: 48, basis: "Each changed row reads its 8-byte key and tick, and moves a 12-byte entry out of one bucket and into another." }, + run: async () => { + const world = inline(); + await world.run(seedKeys, { count: million, marked: million / 100 }); + await world.run(lookOnce); + const best = await after(async () => { flip = -flip; await world.run(rekeyMarked); }, () => world.run(lookOnce)); + check(found > 0, "index refresh 1M 1%: nothing found"); + await world.dispose(); + return best; + }, + }, + { + name: "index rebuild 1M main", + operation: "Building a hash index of 1M entities again after a system moved every one of them to another key, on the world's thread: an index whose keys all change at once.", + per: "entities", + n: million, + typical: [10_000, 1_000_000], + best: { bytes: 24, basis: "Each entity reads its 8-byte key and location and writes a 12-byte entry." }, + run: async () => { + const world = inline(); + await world.run(seedKeys, { count: million, marked: 0 }); + await world.run(lookOnce); + const best = await after(async () => { flip = -flip; await world.run(rekeyAll); }, () => world.run(lookOnce), 12, 2); + await world.dispose(); + return best; + }, + }, + { + name: "index rebuild 1M workers", + operation: "Building a hash index of 1M entities again after a system moved every one of them to another key, across the pool: an index whose keys all change at once.", + per: "entities", + n: million, + typical: [10_000, 1_000_000], + best: { bytes: 24, basis: "Each entity reads its 8-byte key and location and writes a 12-byte entry.", on: "threads" }, + run: async () => { + const world = pooled(); + await world.run(seedKeys, { count: million, marked: 0 }); + await world.run(startPool); + await world.run(lookOnce); + const best = await after(async () => { flip = -flip; await world.run(rekeyAll); }, () => world.run(lookOnce), 12, 2); + await world.dispose(); + return best; + }, + }, + { + name: "rekey 1M", + operation: "A frame of 1M particles drifting a quarter unit, a kernel writing each one's cell of 4 units only when it crosses into another, and the index on the cell refreshed for its reader: a computed key kept as a component.", + per: "rows", + n: drifting, + typical: [10_000, 1_000_000], + best: { bytes: 36, basis: "Each row reads and writes its 12-byte position and reads its 12-byte cell; a crossing also moves its 12-byte entry." }, + run: async () => { + const world = inline(); + await world.run(seedDrifting); + await world.run(lookCoarse); + const best = await fastest(() => world.run(frame), 1000, 2); + check(found > 0, "rekey 1M: nothing found"); + await world.dispose(); + return best; + }, + }, + { + name: "rekey 1M unindexed", + operation: "The frame of `rekey 1M` without the index: the drift and the kernel computing each particle's cell, what the key costs before the index keeps it.", + per: "rows", + n: drifting, + typical: [10_000, 1_000_000], + best: { bytes: 36, basis: "Each row reads and writes its 12-byte position and reads its 12-byte cell." }, + run: async () => { + const world = inline(); + await world.run(seedDrifting); + const best = await fastest(() => world.run(frameUnindexed), 1000, 2); + await world.dispose(); + return best; + }, + }, + { + name: "order run 100K", + operation: "A main-thread system running 100K rows in the order of a sorted index whose ranks are scattered through storage: views laid out by sibling rank, timelines in time order.", + per: "rows", + n: orderedRows, + typical: [100, 100_000], + best: { bytes: 20, basis: "Each row reads its 12-byte entry in key order and its 8-byte value." }, + run: async () => { + const world = inline(); + await world.run(seedRanked); + const best = await fastest(() => world.run(inRankOrder), 2000, 3); + await world.dispose(); + return best; + }, + }, + { + name: "order run 100K storage order", + operation: "The system of `order run 100K` without `order`, its rows in storage order: what running the rows costs alone.", + per: "rows", + n: orderedRows, + typical: [100, 100_000], + best: { bytes: 8, basis: "Each row reads its 8-byte value." }, + run: async () => { + const world = inline(); + await world.run(seedRanked); + const best = await fastest(() => world.run(inStorageOrder), 2000, 3); + await world.dispose(); + return best; + }, + }, + { + name: "order filtered 100K 1%", + operation: "A main-thread system with a change filter running, in a sorted index's order, the 1% of 100K rows that changed since it last ran: reacting to edits in document order.", + per: "rows changed", + n: orderedRows / 100, + typical: [10, 10_000], + best: { bytes: 40, basis: "Each changed row reads its tick, its entry's position and its 12-byte entry, and its 8-byte value." }, + run: async () => { + const world = inline(); + await world.run(seedRanked); + await world.run(changedInRankOrder); + const best = await after(() => world.run(bumpMarked), () => world.run(changedInRankOrder)); + await world.dispose(); + return best; + }, + }, + { + name: "unique lookup 1M", + operation: "1M lookups by key through a unique index, each reading one value of the row it finds, in a main-thread system: a replication map from server ids to entities.", + per: "lookups", + n: million, + typical: [100, 100_000], + best: { bytes: 24, basis: "Each lookup reads its bucket's 8-byte slot and its 12-byte entry, and a 4-byte value." }, + run: async () => { + const world = inline(); + await world.run(seedSerials); + const best = await fastest(() => world.run(lookUnique), 2000, 3); + check(summed === million, "unique lookup 1M: wrong sum"); + await world.dispose(); + return best; + }, + }, + { + name: "children lookup", + operation: "Each of 10K parents summing a value of its ten children through an index on their parent reference, in a main-thread system: a reverse relationship.", + per: "children", + n: parents * 10, + typical: [100, 100_000], + best: { bytes: 16, basis: "Each child is a 12-byte entry and a 4-byte value read." }, + run: async () => { + const world = inline(); + await world.run(seedFamilies); + summed = 0; + const best = await fastest(() => world.run(sumChildren), 2000, 3); + check(summed > 0, "children lookup: nothing summed"); + await world.dispose(); + return best; + }, + }, + { + name: "sorted range 1M", + operation: "10K lookups of a range of ten keys through a sorted index of 1M entities, each reading a value of its rows, in a main-thread system: timelines, intervals, nearest by order.", + per: "range lookups", + n: ranges, + typical: [100, 100_000], + best: { bytes: 192, basis: "Each lookup reads two lines of the blocks' first keys and ten 16-byte entries and their 4-byte values." }, + run: async () => { + const world = inline(); + await world.run(seedSerials); + const best = await fastest(() => world.run(lookRanges), 2000, 3); + check(summed === ranges * 10, "sorted range 1M: wrong sum"); + await world.dispose(); + return best; + }, + }, + { + name: "sorted page 100K", + operation: "A run that changes one entity's value, and a system reading the 20 rows after the first 100 with a small key, highest values first, through a sorted index on the value, recomputed when it changed: `select page 100K` as a system reading an index.", + per: "rows", + n: pageRows, + typical: [1_000, 100_000], + best: { bytes: 16, basis: "Each row reads its 8-byte rank and its 8-byte value, as `select page 100K`'s rows do." }, + run: async () => { + const world = inline(); + await world.run(seedPage); + const best = await fastest(() => world.run(nudgeThenPage)); + check(page.length === 20, "sorted page 100K: wrong page"); + await world.dispose(); + return best; + }, + }, + { + name: "grid neighbors 100K main", + operation: "Each of 100K particles reading the positions of those in the 27 cells around its own through a hash index on its cell, in a main-thread system: a uniform grid broadphase.", + per: "rows", + n: particles, + typical: [1_000, 100_000], + best: { bytes: 1000, basis: "Each row probes 27 buckets, a 64-byte line each, and reads about 270 neighbors' 12-byte entries and positions." }, + run: async () => { + const world = inline(); + await world.run(seedParticles); + const best = await fastest(() => world.run(neighbors), 3000, 2); + check(Number.isFinite(pushed), "grid neighbors 100K main: no push"); + await world.dispose(); + return best; + }, + }, + { + name: "grid neighbors 100K workers", + operation: "Each of 100K particles reading the positions of those in the 27 cells around its own through a hash index on its cell, in a kernel across the pool: a uniform grid broadphase.", + per: "rows", + n: particles, + typical: [1_000, 100_000], + best: { bytes: 1000, basis: "Each row probes 27 buckets, a 64-byte line each, and reads about 270 neighbors' 12-byte entries and positions.", on: "threads" }, + run: async () => { + const world = pooled(); + await world.run(seedKernelParticles); + const best = await fastest(() => world.run(neighborsKernel), 3000, 2); + await world.dispose(); + return best; + }, + }, +]; diff --git a/packages/ecs/src/perftest/joined.ts b/packages/ecs/src/perftest/joined.ts new file mode 100644 index 000000000..8151a939c --- /dev/null +++ b/packages/ecs/src/perftest/joined.ts @@ -0,0 +1,95 @@ +/// +import type { MessagePort as NodeMessagePort } from "node:worker_threads"; +import { join } from "../implementation/index.js"; +import { scopeChannel } from "../implementation/world/port.js"; +import type { World } from "../interface/world/world.js"; +import { counter, empty, order, remoteRuns, observerAmounts, observerCaptions, observerCounter, observerSeen, observerSelect, type FromJoined, type ToJoined } from "./remote.js"; +import { handoffSystems } from "./handoff.js"; +import { release } from "./finalized.js"; +import { tailSystems } from "./tails.js"; +import { take, transferBatch, transferBytes, transferCount, transferSide, transferSystems } from "./transfers.js"; +import { peersOf } from "../implementation/world/join.js"; +import { Observe } from "../observe/index.js"; +import { programFinalizers, requireDefinition } from "../interface/registry/index.js"; + +/** + * A thread joined to a benchmark's world: it times runs, or reads after each sync point until asked to stop, or claims + * the thread the handoff benchmarks' systems run on, the one the despawn objects benchmark's finalizer runs on, or the + * one the tail benchmarks' tails run on. + */ +type Message = ToJoined + | { readonly kind: "port"; readonly port: MessagePort | NodeMessagePort; readonly threads?: readonly ("handoffBenchmark" | "finalizedBenchmark" | "tailsBenchmark" | "transfersBenchmark")[] } + | { readonly kind: "transfers" }; + +const channel = scopeChannel(); +let remote: World | null = null; +let stop: (() => void) | null = null; +let last = 0; +let notified = 0; + +channel.onMessage(async message => { + if (message.kind === "port") { + // The systems the handoff benchmarks place on the thread it claims, which it imports so it runs them. + if (message.threads !== undefined) for (const system of [...handoffSystems, ...tailSystems, ...transferSystems]) requireDefinition(system); + // The finalizer the despawn benchmark places there, which importing its module declared. + if (!programFinalizers().some(finalizer => finalizer === release)) throw new Error("joined: the despawn objects benchmark's finalizer isn't declared here."); + remote = join(message.port, { threads: message.threads ?? [] }); + channel.post({ kind: "joined" }); + return; + } + if (remote === null) return; + if (message.kind === "transfers") { + // The loader moves each run's values straight to the claimed thread once the world gave it a channel there. + const world = remote; + while ((peersOf.get(world)?.() ?? 0) === 0) await new Promise(resolve => setTimeout(resolve, 1)); + const pixels = new ImageData(transferSide, transferSide); + const batch = async () => { + const assets = await Promise.all(Array.from({ length: transferBatch }, async () => ({ buffer: new ArrayBuffer(transferBytes), bitmap: await createImageBitmap(pixels) }))); + const start = performance.now(); + await Promise.all(assets.map(asset => world.run(take, asset))); + return performance.now() - start; + }; + await batch(); + let milliseconds = 0; + for (let k = 0; k < transferCount / transferBatch; k++) milliseconds += await batch(); + channel.post({ kind: "transferred", milliseconds }); + return; + } + if (message.kind === "runs") { + for (let k = 0; k < 100; k++) await remote.run(empty); + let best = Infinity; + for (let stretch = 0; stretch < message.stretches; stretch++) { + const start = performance.now(); + for (let k = 0; k < remoteRuns; k++) await remote.run(empty); + best = Math.min(best, performance.now() - start); + } + channel.post({ kind: "ran", milliseconds: best }); + return; + } + if (message.kind === "stop") { + stop?.(); + channel.post({ kind: "stopped", last: Math.max(last, observerSeen.last), notified: notified + observerSeen.notified }); + return; + } + if (message.kind === "observer resource" || message.kind === "observer select" || message.kind === "observer captions" || message.kind === "observer amounts") { + // An observer system of this thread's own, which the world posts here after each sync point. + const system = message.kind === "observer resource" ? observerCounter : message.kind === "observer select" ? observerSelect + : message.kind === "observer captions" ? observerCaptions : observerAmounts; + const world = remote; + world.add(system); + stop = () => world.remove(system); + channel.post({ kind: "observing" }); + return; + } + let first = true; + const observing = (value: unknown): void => { + notified++; + last = typeof value === "number" ? value : Array.isArray(value) ? value.length : -1; + if (!first) return; + first = false; + channel.post({ kind: "observing" }); + }; + stop = message.kind === "factory resource" + ? Observe.resource(remote, counter)(observing) + : Observe.select(remote, { required: [order], order: { [order]: true } })(observing); +}); diff --git a/packages/ecs/src/perftest/keys.ts b/packages/ecs/src/perftest/keys.ts new file mode 100644 index 000000000..d256fb96f --- /dev/null +++ b/packages/ecs/src/perftest/keys.ts @@ -0,0 +1,100 @@ +/** + * A microbenchmark of reading a values object's symbol keys, as commands and spawns do: each round builds the + * object literal a spawn site would, then reads every key and its value. Run with `node dist/perf/run.js --keys` + * after `pnpm exec vite build --config vite.perf.config.ts`; it prints nanoseconds per object by method and key count. + */ + +const keys: readonly symbol[] = Array.from({ length: 6 }, (_, k) => Symbol(`keys/${k}`)); +const [k0, k1, k2, k3, k4, k5] = keys; + +/** Each count's literal, so every object of a count has one shape, as one spawn site's do. */ +const literals: readonly ((i: number) => object)[] = [ + i => ({ [k0]: i }), + i => ({ [k0]: i, [k1]: i }), + i => ({ [k0]: i, [k1]: i, [k2]: i }), + i => ({ [k0]: i, [k1]: i, [k2]: i, [k3]: i }), + i => ({ [k0]: i, [k1]: i, [k2]: i, [k3]: i, [k4]: i }), + i => ({ [k0]: i, [k1]: i, [k2]: i, [k3]: i, [k4]: i, [k5]: i }), +]; + +/** Reads every symbol key and value; returns a sum so nothing is optimized away. */ +type Method = (values: object) => number; + +const methods: readonly (readonly [string, Method])[] = [ + // Building the literal alone, the cost every method includes. + ["the literal alone", () => 0], + ["Object.keys, then symbols", values => { + let sum = 0; + for (const key of Object.keys(values)) sum += Number(Reflect.get(values, key)); + for (const key of Object.getOwnPropertySymbols(values)) sum += Number(Reflect.get(values, key)); + return sum; + }], + ["Object.getOwnPropertySymbols", values => { + let sum = 0; + const found = Object.getOwnPropertySymbols(values); + for (let k = 0; k < found.length; k++) sum += Number(Reflect.get(values, found[k])); + return sum; + }], + // Not a way to read unknown keys: values read by keys known in advance, to separate listing keys from reading values. + ["known keys, no listing", values => { + let sum = 0; + for (let k = 0; k < keys.length; k++) { + const value: unknown = Reflect.get(values, keys[k]); + if (value !== undefined) sum += Number(value); + } + return sum; + }], + ["Reflect.ownKeys, symbols only", values => { + let sum = 0; + const found = Reflect.ownKeys(values); + for (let k = 0; k < found.length; k++) { + const key = found[k]; + if (typeof key === "symbol") sum += Number(Reflect.get(values, key)); + } + return sum; + }], +]; + +const rounds = 1_000_000; + +/** The fastest of several timed passes, in nanoseconds per object. */ +const time = (method: Method, literal: (i: number) => object): number => { + let best = Infinity; + let sink = 0; + for (let pass = 0; pass < 7; pass++) { + const start = performance.now(); + for (let i = 0; i < rounds; i++) sink += method(literal(i)); + best = Math.min(best, performance.now() - start); + } + if (sink === -1) console.log(sink); + return (best * 1e6) / rounds; +}; + +const names: readonly string[] = keys.map((_, k) => `keys/${k}`); +const [s0, s1, s2, s3, s4, s5] = names; + +/** The same literals keyed by strings, for comparison with string keys read by `Object.keys`. */ +const stringLiterals: readonly ((i: number) => object)[] = [ + i => ({ [s0]: i }), + i => ({ [s0]: i, [s1]: i }), + i => ({ [s0]: i, [s1]: i, [s2]: i }), + i => ({ [s0]: i, [s1]: i, [s2]: i, [s3]: i }), + i => ({ [s0]: i, [s1]: i, [s2]: i, [s3]: i, [s4]: i }), + i => ({ [s0]: i, [s1]: i, [s2]: i, [s3]: i, [s4]: i, [s5]: i }), +]; + +const stringKeys: Method = values => { + let sum = 0; + const found = Object.keys(values); + for (let k = 0; k < found.length; k++) sum += Number(Reflect.get(values, found[k])); + return sum; +}; + +/** Prints one line per method: nanoseconds per object for 1 to 6 keys. */ +export const runKeyReading = (): void => { + console.log(["method".padEnd(32), ...literals.map((_, k) => `${k + 1} key${k === 0 ? "" : "s"}`.padStart(8))].join("")); + for (const [name, method] of methods) { + console.log([name.padEnd(32), ...literals.map(literal => `${time(method, literal).toFixed(1)}`.padStart(8))].join("")); + } + console.log(["string keys, Object.keys".padEnd(32), ...stringLiterals.map(literal => `${time(stringKeys, literal).toFixed(1)}`.padStart(8))].join("")); +}; diff --git a/packages/ecs/src/perftest/log.ts b/packages/ecs/src/perftest/log.ts new file mode 100644 index 000000000..568cffd60 --- /dev/null +++ b/packages/ecs/src/perftest/log.ts @@ -0,0 +1,126 @@ +import { Component } from "../interface/component/component.js"; +import { createBuffer } from "../implementation/world/buffer.js"; +import { createCommands } from "../implementation/world/commands.js"; +import { asEntity } from "../implementation/world/entities.js"; +import { cellOf, createMirror, nameComponent, rebuild, replay, type Mirror } from "../implementation/world/mirror.js"; +import { createMoveLog } from "../implementation/world/move-log.js"; +import { claimThreads } from "../implementation/world/remote.js"; +import { createState, defaultOf, idOf, type State } from "../implementation/world/state.js"; +import { synchronize } from "../implementation/world/synchronize.js"; +import type { Benchmark } from "./benchmark.js"; + +// A joined thread's object columns following the world's rows: replaying the move log, and rebuilding from the layout +// directory once it lapped. The world and the thread's mirror are on this thread, the mirror timed alone. + +const serial = Symbol("log/serial"); +const label = Symbol("log/label"); +const moved = Symbol("log/moved"); +declare module "@adobe/ecs" { + export interface Threads { + logBenchmark: true; + } + export interface Components { + [serial]: { type: number }; + [label]: { type: { readonly text: string } | null; thread: "logBenchmark" }; + [moved]: { type: true }; + } +} +Component.register(serial, { type: { type: "number" } }); +const none = { text: "none" }; +Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "logBenchmark" }); +Component.register(moved, { type: { const: true } }); + +/** Entities moved, and swapped out of the rows they leave, per sync point of `log replay`. */ +export const replayMoves = 10_000; +/** Rows `log rebuild 100K` rebuilds. */ +export const rebuildRows = 100_000; + +/** A world whose "logBenchmark" objects a mirror on this thread fills, with `count` entities holding one each. */ +const joinedWorld = (count: number): { readonly state: State; readonly mirror: Mirror; readonly entities: readonly number[] } => { + const state = createState(true); + let mirror: Mirror | null = null; + const log = createMoveLog(state.memory, 1, state.starts, component => { + const key = state.keys[component]; + if (mirror !== null && typeof key === "symbol") nameComponent(mirror, component, key, defaultOf); + }); + mirror = createMirror(state.memory.memory, log.block, 1); + for (const key of [serial, label, moved]) idOf(state, key); + claimThreads(state, log, ["logBenchmark"]); + const target = { buffer: createBuffer() }; + const commands = createCommands(state, target); + const entities: number[] = []; + for (let k = 0; k < count; k++) entities.push(commands.spawn([serial, label])({ [serial]: k })); + synchronize(state); + if (!rebuild(mirror)) throw new Error("log: the mirror didn't rebuild."); + return { state, mirror, entities }; +}; + +/** + * The fastest of `rounds` replays of a sync point moving `replayMoves` of 20K entities to another archetype holding the + * thread's objects, and back the next, each move leaving a row the archetype's last swaps into. + */ +const logReplay = async (rounds = 40): Promise => { + const { state, mirror, entities } = joinedWorld(2 * replayMoves); + const target = { buffer: createBuffer() }; + const commands = createCommands(state, target); + let best = Infinity; + for (let round = 0; round < rounds; round++) { + for (let k = 0; k < replayMoves; k++) { + // Every other one, so the rows they leave are spread over the archetype. + const entity = asEntity(entities[2 * k]); + if (round % 2 === 0) commands.add(entity, moved); + else commands.remove(entity, moved); + } + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + const start = performance.now(); + const replayed = replay(mirror); + const time = performance.now() - start; + // The first sync point with the rows in place grows the ring to hold them, which a thread rebuilds after. + if (replayed === "lapped" && round === 0 && rebuild(mirror)) continue; + if (replayed !== "current") throw new Error(`log replay: the mirror's replay was ${replayed}.`); + if (round > 1) best = Math.min(best, time); + await Promise.resolve(); + } + const last = state.archetypes.find(archetype => archetype.count === replayMoves * 2); + if (last === undefined || cellOf(mirror, last.id, 0, idOf(state, label))?.value !== none) throw new Error("log replay: the mirror doesn't hold the rows' objects."); + return best; +}; + +/** The fastest of `rounds` rebuilds of `rebuildRows` rows from the layout directory, as after the log lapped. */ +const logRebuild = async (rounds = 20): Promise => { + const { state, mirror } = joinedWorld(rebuildRows); + let best = Infinity; + for (let round = 0; round < rounds; round++) { + const start = performance.now(); + const rebuilt = rebuild(mirror); + const time = performance.now() - start; + if (!rebuilt) throw new Error("log rebuild: the mirror didn't rebuild."); + if (round > 1) best = Math.min(best, time); + await Promise.resolve(); + } + const archetype = state.archetypes.find(candidate => candidate.count === rebuildRows); + if (archetype === undefined || cellOf(mirror, archetype.id, rebuildRows - 1, idOf(state, label))?.value !== none) throw new Error("log rebuild: the mirror doesn't hold the rows' objects."); + return best; +}; + +export const logBenchmarks: readonly Benchmark[] = [ + { + name: "log replay", + operation: "A joined thread replaying a sync point's move log: 10K entities holding its objects moving archetype, each leaving a row the last swaps into, as when a tag on DOM-backed entities toggles.", + per: "moves", + n: replayMoves, + typical: [1, 10_000], + best: { bytes: 128, basis: "Each move copies out and reads two 16-byte entries, and copies its object, ticks and entity twice, 32 bytes read and written each." }, + run: () => logReplay(), + }, + { + name: "log rebuild 100K", + operation: "A joined thread rebuilding its object columns over 100K rows from the layout directory, as when it fell further behind than its log holds.", + per: "rows", + n: rebuildRows, + typical: [1_000, 100_000], + best: { bytes: 60, basis: "Each row reads its 8-byte entity, 4-byte tag and 16 bytes of ticks, and writes its entity, object and ticks." }, + run: () => logRebuild(), + }, +]; diff --git a/packages/ecs/src/perftest/loop.ts b/packages/ecs/src/perftest/loop.ts new file mode 100644 index 000000000..ab9419065 --- /dev/null +++ b/packages/ecs/src/perftest/loop.ts @@ -0,0 +1,49 @@ +import { always } from "../interface/resources/always.js"; +import { System } from "../interface/system/system.js"; +import { backToBack, createWorld } from "../implementation/index.js"; +import { idle, unused } from "./suite.js"; +import { eachTick } from "./observer.js"; + +/** Ticks in each timed stretch of `loopTickIdle`. */ +export const loopTicks = 10_000; + +/** Observer systems in `observer idle 100`. */ +export const observerIdles = 100; + +/** Observer systems no entity matches, each run after every tick. */ +const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ name: `loop/idleObserver${i}`, observer: true, components: { has: [unused] } }, () => {})); + +/** + * The fastest of `stretches` stretches of `loopTicks` ticks with nothing changed, after one to warm up: a world with + * `always` set, one system no entity matches and `observer` observer systems none matches, driven back to back, counted + * by an observer system reading its clock. + */ +export const loopTickIdle = async (stretches = 5, observer = 0): Promise => { + const driver = backToBack(); + const world = createWorld({ [always]: true }, { loop: driver }); + let best = Infinity; + try { + await new Promise(resolve => { + let ticks = 0; + let stretch = 0; + let start = 0; + const unobserve = eachTick(world, () => { + if (ticks++ % loopTicks !== 0) return; + const now = performance.now(); + // The first stretch warms up. + if (stretch > 1) best = Math.min(best, now - start); + start = now; + if (stretch++ <= stretches) return; + unobserve(); + resolve(); + }); + for (let k = 0; k < observer; k++) world.add(idleObserver[k]); + world.add(idle); + }); + } + finally { + await world.dispose(); + driver.close(); + } + return best; +}; diff --git a/packages/ecs/src/perftest/observed-gpu.ts b/packages/ecs/src/perftest/observed-gpu.ts new file mode 100644 index 000000000..b172e3e23 --- /dev/null +++ b/packages/ecs/src/perftest/observed-gpu.ts @@ -0,0 +1,58 @@ +import { Component } from "../interface/component/component.js"; +import type { Entity } from "../interface/entity/entity.js"; +import { gpuDevice } from "../interface/resources/gpu-device.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/world/create.js"; +import { Observe } from "../observe/index.js"; +import type { Benchmark } from "./benchmark.js"; + +// An entity observer factory of values a GPU kernel writes: each run reads back what the observer reads. + +const heat = Symbol("observedGpu/heat"); +const rate = Symbol("observedGpu/rate"); +declare module "@adobe/ecs" { + export interface Components { + [heat]: { type: number }; + [rate]: { type: number }; + } +} +Component.register(heat, { type: { type: "number", precision: 1 } }); +Component.register(rate, { type: { type: "number", precision: 1 } }); + +const heated = 100_000; +let spawned: Entity[] = []; +const spawn = System.create({ name: "observedGpu/spawn" }, ({ commands }) => { + spawned = []; + for (let i = 0; i < heated; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: 0, [rate]: 1 })); +}); +const warm = System.create({ name: "observedGpu/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { + row[heat] = row[heat] + row[rate]; +}); + +/** Timed per run by `timed`, which waits for the GPU. */ +export const observedGpuBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ + name: "observe gpu entity factory", + operation: "A GPU kernel writing a value of 100K entities, one entity of which is observed through the observer factories: each run reads that storage back for it.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 400_000, basis: "The readback copies the observed storage's 100K 4-byte values.", on: "gpu" }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + let notified = 0; + const stop = Observe.entity(world, spawned[0], { required: [heat] })(() => { notified++; }); + await world.run(warm); + const before = notified; + const best = await timed(() => world.run(warm)); + if (notified <= before) throw new Error("observe gpu entity factory: not notified"); + stop(); + return best; + } + finally { + await world.dispose(); + } + }, +}]; diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts new file mode 100644 index 000000000..0e69778b3 --- /dev/null +++ b/packages/ecs/src/perftest/observed.ts @@ -0,0 +1,298 @@ +import { Component } from "../interface/component/component.js"; +import type { Entity } from "../interface/entity/entity.js"; +import { Resource } from "../interface/resource/resource.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import { Observe } from "../observe/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// Benchmarks of the internal observer factories of `src/observe`. + +const level = Symbol("observed/level"); +const value = Symbol("observed/value"); +const rank = Symbol("observed/rank"); +const marked = Symbol("observed/marked"); +const unmatched = Symbol("observed/unmatched"); +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [rank]: { type: number }; + [marked]: { type: true }; + [unmatched]: { type: true }; + } + export interface Resources { + [level]: { type: number }; + } +} +Resource.register(level, { type: { type: "number", default: 3 } }); +Component.register(value, { type: { type: "number" } }); +Component.register(rank, { type: { type: "number" } }); +Component.register(marked, { type: { const: true } }); +Component.register(unmatched, { type: { const: true } }); + +/** `count` entities with a value and a rank, the first of them marked. */ +const seed = System.create({ name: "observed/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); + for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); +}); + +/** Writes the marked entity's value, so one entity observer is notified and a selection or count is a candidate. */ +const nudge = System.create({ name: "observed/nudge", components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); + +/** A kernel writing every row's rank. */ +const advance = System.create({ name: "observed/advance", kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); + +/** A system no entity matches: a run that changes nothing. */ +const idle = System.create({ name: "observed/idle", components: { has: [unmatched] } }, () => {}); + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +/** Resolves once observers subscribed before it had their first values: a run after the factories flush. */ +const settled = async (world: World): Promise => { + await Promise.resolve(); + await world.run(idle); +}; + +const seeded = async (count: number): Promise => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count }); + return world; +}; + +/** Every entity a seeded world holds. */ +const entitiesOf = (world: World): Promise => Observe.toPromise(Observe.select(world, { required: [value] })); + +/** As `fastest`: the fastest timed run of `run` within `budget` milliseconds of them, each after an untimed `prepare`. */ +const fastestPrepared = async (prepare: () => Promise, run: () => Promise, budget = 1000): Promise => { + for (let warm = 0; warm < 10; warm++) { + await prepare(); + await run(); + } + let best = Infinity; + let spent = 0; + while (spent < budget) { + await prepare(); + const start = performance.now(); + await run(); + const time = performance.now() - start; + spent += time; + best = Math.min(best, time); + } + return best; +}; + +type SelectQuery = Parameters>[1]; + +const observedEntities = 1_000; +const movingEntities = 100_000; +const selectedRows = 100_000; +const countedEntities = 1_000_000; + +let copied = 0; + +/** A world with an entity observer of each of its entities, which had its first value. */ +const observedWorld = async (): Promise<{ readonly world: World; readonly stop: () => void; readonly notified: () => number }> => { + const world = await seeded(observedEntities); + let notified = 0; + const stops = (await entitiesOf(world)).map(entity => Observe.entity(world, entity, { required: [value] })(() => { notified++; })); + await settled(world); + notified = 0; + return { world, stop: () => { for (const stop of stops) stop(); }, notified: () => notified }; +}; + +/** A page of `select page 100K` and `select page 100K churn`: a sorted, filtered list of a UI. */ +const pageQuery = (offset: number): SelectQuery => ({ required: [value, rank], where: { [rank]: { "<": selectedRows / 2 } }, order: { [value]: false }, page: { offset, limit: 20 } }); + +export const observedBenchmarks: readonly Benchmark[] = [ + { + name: "read observe factory warm", + operation: "Reading a resource through an observer factory's first value, `Observe.toPromise(Observe.resource(world, key))`, while the factories hold its value: a value read from outside the world, again.", + per: "reads", + n: 1, + typical: [1, 100], + best: { bytes: 64, basis: "A read loads at least one cache line of the resource's cell." }, + run: async () => { + const world = await seeded(10_000); + const best = await fastest(async () => { copied = await Observe.toPromise(Observe.resource(world, level)); }); + check(copied === 3, "read observe factory warm: wrong value"); + await world.dispose(); + return best; + }, + }, + { + name: "read observe factory cold", + operation: "Reading a resource through an observer factory's first value while the factories hold nothing of it, their observer system for it removed after idle passes: a run of observer systems for one read.", + per: "reads", + n: 1, + typical: [1, 100], + best: { bytes: 64, basis: "A read loads at least one cache line of the resource's cell." }, + run: async () => { + const world = await seeded(10_000); + const evict = async (): Promise => { + for (let i = 0; i < 3; i++) await world.run(idle); + }; + const best = await fastestPrepared(evict, async () => { copied = await Observe.toPromise(Observe.resource(world, level)); }); + check(copied === 3, "read observe factory cold: wrong value"); + await world.dispose(); + return best; + }, + }, + { + name: "observe 1K entities factory", + operation: "A run that changes nothing with an entity observer on each of 1K entities, through the observer factories: what watching entities costs each frame.", + per: "observers", + n: observedEntities, + typical: [10, 1_000], + best: { bytes: 16, basis: "Each observer reads its entity's 8-byte location and its component's 8-byte changed tick." }, + run: async () => { + const { world, stop, notified } = await observedWorld(); + const best = await fastest(() => world.run(idle)); + check(notified() === 0, "observe 1K entities factory: notified"); + stop(); + await world.dispose(); + return best; + }, + }, + { + name: "observe 1K entities one changed factory", + operation: "A run that changes one of 1K entities, each with an entity observer, through the observer factories: what watching entities costs each frame while one of them changes.", + per: "observers", + n: observedEntities, + typical: [10, 1_000], + best: { bytes: 16, basis: "Each observer reads its entity's 8-byte location and its component's 8-byte changed tick." }, + run: async () => { + const { world, stop, notified } = await observedWorld(); + const best = await fastest(() => world.run(nudge)); + check(notified() > 0, "observe 1K entities one changed factory: not notified"); + stop(); + await world.dispose(); + return best; + }, + }, + { + name: "observe one entity among 100K moving factory", + operation: "A kernel writing the rank of 100K entities, one of them observed, through the observer factories: a debugging view of one entity in a moving world, beyond the kernel's own time.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 800_000, basis: "The kernel reads and writes 100K 4-byte ranks; observing one entity adds a cache line." }, + run: async () => { + const world = await seeded(movingEntities); + const [first] = await entitiesOf(world); + let notified = 0; + const stop = Observe.entity(world, first, { required: [rank] })(() => { notified++; }); + await world.run(advance); + const before = notified; + const best = await fastest(() => world.run(advance)); + check(notified > before, "observe one entity among 100K moving factory: not notified"); + stop(); + await world.dispose(); + return best; + }, + }, + { + name: "observe subscribe churn 1K factory", + operation: "Subscribing an entity observer to each of 1K entities, a run, and stopping them all, through the observer factories: views opened and closed each frame.", + per: "observers", + n: observedEntities, + typical: [10, 1_000], + best: { bytes: 16, basis: "Each observer reads its entity's 8-byte location and its component's 8-byte value." }, + run: async () => { + const world = await seeded(observedEntities); + const entities = await entitiesOf(world); + let notified = 0; + const notify = (): void => { notified++; }; + const best = await fastest(async () => { + const stops = entities.map(entity => Observe.entity(world, entity, { required: [value] })(notify)); + await world.run(idle); + for (const stop of stops) stop(); + }); + check(notified > 0, "observe subscribe churn 1K factory: no first values"); + await world.dispose(); + return best; + }, + }, + { + name: "select page 100K factory", + operation: "A run that changes one entity's order component under an observer factory's `select` with `where`, `order` and a 20-row `page` over 100K entities: a sorted, filtered list of a UI, kept current through a sorted index.", + per: "rows", + n: selectedRows, + typical: [1_000, 100_000], + best: { bytes: 16, basis: "Each row reads its 8-byte rank and its 8-byte value." }, + run: async () => { + const world = await seeded(selectedRows); + let page: readonly Entity[] = []; + const stop = Observe.select(world, pageQuery(100))(selected => { page = selected; }); + await settled(world); + const best = await fastest(() => world.run(nudge)); + check(page.length === 20, "select page 100K factory: wrong page"); + stop(); + await world.dispose(); + return best; + }, + }, + { + name: "select page 100K churn factory", + operation: "The first value of a 20-row page of a `select` with `where` and `order` over 100K entities, at another offset each time, while another observer of that query keeps it current, through the observer factories: a UI paging.", + per: "reads", + n: 1, + typical: [1, 100], + best: { bytes: 320, basis: "A page reads 20 entries of 16 bytes: an entity, its rank and its value." }, + run: async () => { + const world = await seeded(selectedRows); + const stop = Observe.select(world, pageQuery(0))(() => {}); + await settled(world); + let offset = 0; + let page: readonly Entity[] = []; + const best = await fastest(async () => { + offset = (offset + 20) % 2_000; + page = await Observe.toPromise(Observe.select(world, pageQuery(offset))); + }); + check(page.length === 20, "select page 100K churn factory: wrong page"); + stop(); + await world.dispose(); + return best; + }, + }, + { + name: "select factory", + operation: "The first value of an observer factory's `select` with `where` over 1M entities, which the factories hold nothing of: one scan, a run of an observer system over the rows that match.", + per: "rows", + n: 1_000_000, + typical: [100, 100_000], + best: { bytes: 8, basis: "Each row reads its 4-byte rank; each selected half writes its 8-byte entity." }, + run: async () => { + const world = await seeded(1_000_000); + const best = await fastest(async () => { + const selected = await Observe.toPromise(Observe.select(world, { required: [value, rank], where: { [rank]: { "<": 500_000 } } })); + check(selected.length === 500_000, "select factory: wrong count"); + }); + await world.dispose(); + return best; + }, + }, + { + name: "count 1M changed factory", + operation: "A run that changes one entity's value under a `count` with `where` over 1M entities, through the observer factories: a filtered total of a UI, counted again after a change.", + per: "rows", + n: countedEntities, + typical: [1_000, 1_000_000], + best: { bytes: 8, basis: "Each row reads its 8-byte value." }, + run: async () => { + const world = await seeded(countedEntities); + let counted = 0; + const stop = Observe.count(world, { required: [value], where: { [value]: { "<": countedEntities / 2 } } })(next => { counted = next; }); + await settled(world); + const best = await fastest(() => world.run(nudge)); + check(counted > 0, "count 1M changed factory: nothing counted"); + stop(); + await world.dispose(); + return best; + }, + }, +]; diff --git a/packages/ecs/src/perftest/observer.ts b/packages/ecs/src/perftest/observer.ts new file mode 100644 index 000000000..0bde76913 --- /dev/null +++ b/packages/ecs/src/perftest/observer.ts @@ -0,0 +1,57 @@ +import type { Component } from "../interface/component/component.js"; +import type { Entity } from "../interface/entity/entity.js"; +import { clock } from "../interface/resources/clock.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; + +// Benchmarks read a world as consumers do, through observer systems: a run of one made for a read leaves nothing added +// to the world, so the runs timed after it run what they ran before. + +/** + * Calls `notify` after each tick, and once after the first pass it runs in, from an observer system it adds to `world` + * filtering on the clock, until the function it returns removes it: how benchmarks count ticks back to back, where that + * first call only starts the stretch that warms up. + */ +export const eachTick = (world: World, notify: () => void): (() => void) => { + const counter = System.create({ name: "perftest/eachTick", observer: true, resources: { changed: [clock] } }, () => { notify(); }); + world.add(counter); + return () => world.remove(counter); +}; + +/** The clock as an observer run last read it. */ +const read = { tick: 0, now: 0 }; +const readClock = System.create({ name: "perftest/readClock", observer: true, resources: { read: [clock] } }, ({ resources }) => { + read.tick = resources[clock].tick; + read.now = resources[clock].now; +}); + +/** + * Resolves once `count` ticks ran after the clock an observer run reads now, which doesn't wake the loop. A tick is + * told from the clock before it by its count and its `now` together: the first tick's count is 0, as before it, and + * back-to-back ticks can share a `now` at a coarse timer. + */ +export const ticksAfter = async (world: World, count: number): Promise => { + await world.run(readClock); + let { tick, now } = read; + let seen = 0; + await new Promise(resolve => { + const counter = System.create({ name: "perftest/ticksAfter", observer: true, resources: { read: [clock] } }, ({ resources }) => { + const current = resources[clock]; + if (current.tick === tick && current.now === now) return; + tick = current.tick; + now = current.now; + if (++seen < count) return; + queueMicrotask(() => world.remove(counter)); + resolve(); + }); + world.add(counter); + }); +}; + +/** The entities with every component of `has` and none of `not`, in storage order, read by an observer run. */ +export const entitiesWith = async (world: World, has: readonly Component.Key[], not: readonly Component.Key[] = []): Promise => { + const found: Entity[] = []; + await world.run(System.create({ observer: true, components: { has, not } }, row => { found.push(row.entity); })); + return found; +}; + diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts new file mode 100644 index 000000000..8f62badd5 --- /dev/null +++ b/packages/ecs/src/perftest/particles.ts @@ -0,0 +1,76 @@ +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import type { Schema } from "../interface/schema/schema.js"; +import { System } from "../interface/system/system.js"; + +// Mirrors adobe/data's ecs1/ecs2 perftests (packages/data/src/perftest): 1M particles split evenly across +// 4 archetypes by visible/enabled; move adds velocity to position for the visible and enabled quarter. + +type Vec3 = readonly [number, number, number]; +const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; + +const color = Symbol("perf/color"); +const mass = Symbol("perf/mass"); +export const position = Symbol("perf/position"); +const velocity = Symbol("perf/velocity"); +export const visible = Symbol("perf/visible"); +export const enabled = Symbol("perf/enabled"); + +declare module "@adobe/ecs" { + export interface Components { + [color]: { type: number }; + [mass]: { type: number }; + [position]: { type: readonly [number, number, number] }; + [velocity]: { type: readonly [number, number, number] }; + [visible]: { type: true }; + [enabled]: { type: true }; + } +} + +Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(mass, { type: { type: "number", precision: 1 } }); +Component.register(position, { type: vec3 }); +Component.register(velocity, { type: vec3 }); +Component.register(visible, { type: { const: true } }); +Component.register(enabled, { type: { const: true } }); + +/** Interleaved like ecs2's seedSingle: entity i goes to archetype i % 4. */ +const archetypes = [ + Archetype.create([color, mass, position, velocity, visible, enabled]), + Archetype.create([color, mass, position, velocity, visible]), + Archetype.create([color, mass, position, velocity, enabled]), + Archetype.create([color, mass, position, velocity]), +] as const; + +export const spawn = System.create({ name: "perf/spawn", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) { + const p = [i + 1, i + 1, i + 1] as const; + const v = [-i, -i, -i] as const; + commands.spawn(archetypes[i % 4])({ [color]: 0xff00ff, [mass]: 10, [position]: p, [velocity]: v }); + } +}); + +export const move = System.create({ + name: "perf/move", + kernel: "cpu", + components: { read: [velocity], write: [position], has: [visible, enabled] }, +}, row => { + const [px, py, pz] = row[position]; + const [vx, vy, vz] = row[velocity]; + row[position] = [px + vx, py + vy, pz + vz]; +}); + +/** Fastest of repeated runs within a time budget, like the adobe/data harness. */ +export const fastest = async (run: () => Promise, budget = 1000): Promise<{ readonly minimum: number; readonly passes: number }> => { + for (let warm = 0; warm < 20; warm++) await run(); + let minimum = Infinity; + let passes = 0; + const end = performance.now() + budget; + while (performance.now() < end) { + const start = performance.now(); + await run(); + minimum = Math.min(minimum, performance.now() - start); + passes++; + } + return { minimum, passes }; +}; diff --git a/packages/ecs/src/perftest/placed.ts b/packages/ecs/src/perftest/placed.ts new file mode 100644 index 000000000..2adb8612c --- /dev/null +++ b/packages/ecs/src/perftest/placed.ts @@ -0,0 +1,177 @@ +import type { World } from "../interface/world/world.js"; +import { workers } from "../interface/resources/workers.js"; +import { createWorld } from "../implementation/world/create.js"; +import type { Benchmark, Best } from "./benchmark.js"; +import { forest, propagate } from "./hierarchy.js"; +import { fastest as particlesFastest, move, spawn } from "./particles.js"; +import { chain, emit, fastest, follow, hits, observe, poke, seeded } from "./suite.js"; +import * as structural from "./structural.js"; +import { bumpEach, fillEach, spawnEach } from "./growth.js"; +// Last, so the modules the benchmarks share load in the order they did before it. +import { Observe } from "../observe/index.js"; + +/** Benchmarks run on the world's thread alone and on the pool, in chromium and, for some, in node. */ + +/** Doublings `sync point grow 100 archetypes` times, from archetypes of 16 rows, in each of `rounds` worlds. */ +const doublings = 6; +const rounds = 8; + +/** Every hardware thread but the world's. */ +export const threads = navigator.hardwareConcurrency - 1; + +export type Placement = { readonly label: string; readonly workers: number; readonly on?: Best["on"] }; +export const main: Placement = { label: "main", workers: 0 }; +export const pooled: Placement = { label: "workers", workers: threads, on: "threads" }; + +/** A world of the placement, measured, then disposed. */ +const measured = async (placement: Placement, run: (world: World) => Promise): Promise => { + const world = createWorld({ [workers]: placement.workers }); + try { + return await run(world); + } finally { + await world.dispose(); + } +}; + +export const upOperation = (chains: string): string => `A kernel with \`up\` computing every row from its parent, over ${chains}: transforms, inherited visibility or style, recomputed in full.`; +export const upBest = { bytes: 28, basis: "Each row reads its 8-byte value, its parent's 8-byte result and 4-byte row, and writes its 8-byte result." }; + +/** The benchmarks of each placement. */ +export const placed = (placements: readonly Placement[]): readonly Benchmark[] => [ + ...placements.flatMap((placement): readonly Benchmark[] => [ + { + name: `move ${placement.label}`, + operation: "A kernel adding a vec3 to another for the rows with two tags: integration, animation, any per-row update.", + per: "matched rows", + n: 250_000, + typical: [1_000, 1_000_000], + best: { bytes: 36, basis: "Each row reads 12 bytes of velocity and reads and writes 12 bytes of position.", on: placement.on }, + run: () => measured(placement, async world => { + await world.run(spawn, { count: 1_000_000 }); + return (await particlesFastest(() => world.run(move))).minimum; + }), + }, + { + name: `follow ${placement.label}`, + operation: "A kernel looking up another entity per row and reading its position: steering toward targets, constraints, attachments.", + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 52, basis: "Each row reads 12 bytes of position and an 8-byte target, the target's 8-byte location and 12-byte position, and writes 12.", on: placement.on }, + run: () => measured(placement, async world => { + await seeded(() => world, 1_000_000); + return fastest(() => world.run(follow)); + }), + }, + { + name: `chain ${placement.label}`, + operation: "Six dependent kernels over the same rows, each reading what the one before wrote: a pipeline of per-row steps.", + per: "row passes", + n: 6_000_000, + typical: [6_000, 6_000_000], + best: { bytes: 4, basis: "Fused into one pass, each row reads and writes its 12-byte velocity once for all six.", on: placement.on }, + run: () => measured(placement, async world => { + await seeded(() => world, 1_000_000); + return fastest(() => world.run(chain)); + }), + }, + { + name: `sparse changed ${placement.label}`, + operation: "A kernel filtered on `changed` after 1% of rows changed, scattered: reacting to edits, such as recomputing bounds of what moved.", + per: "rows in the archetype", + n: 1_000_000, + typical: [10_000, 1_000_000], + best: { bytes: 8.24, basis: "Every chunk has a change, so each row's 8-byte tick is read; the changed 1% read 12 bytes and write 12.", on: placement.on }, + run: () => measured(placement, async world => { + await seeded(() => world, 1_000_000); + let frame = 0; + let best = Infinity; + for (let i = 0; i < 200; i++) { + await world.run(poke, { group: frame++ % 100 }); + const start = performance.now(); + await world.run(observe); + if (i >= 20) best = Math.min(best, performance.now() - start); + } + return best; + }), + }, + { + name: `emit ${placement.label}`, + operation: "A kernel issuing an add for a tenth of its rows: hits, events or state changes found by a parallel pass.", + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 34, basis: "Each row reads 16 bytes; each tenth moves to a new archetype, about 180 bytes.", on: placement.on }, + run: () => measured(placement, async world => { + await seeded(() => world, 1_000_000); + const best = await fastest(() => world.run(emit)); + if (await Observe.toPromise(Observe.count(world, { required: [hits] })) !== 100_000) throw new Error(`emit ${placement.label}: wrong count`); + return best; + }), + }, + { + name: `spawn bulk kernel ${placement.label}`, + operation: "A kernel spawning an entity per row over 100K rows: spawns found by a parallel pass, such as debris or hits.", + per: "spawns", + n: 100_000, + typical: [100, 100_000], + best: { bytes: 28, basis: "Each row reads its 4-byte index; each spawn writes its 8 bytes of values, its entity and its location.", on: placement.on }, + run: () => measured(placement, async world => { + await world.run(structural.seed, { count: 100_000, sources: 1 }); + let best = Infinity; + for (let i = 0; i < 40; i++) { + await world.run(structural.despawnSpawned); + const start = performance.now(); + await world.run(structural.spawnKernel); + if (i >= 8) best = Math.min(best, performance.now() - start); + } + return best; + }), + }, + ...[[100_000, 10, "100K chains of 10"], [1_000, 1_000, "1K chains of 1,000"]].map(([roots, depth, chains]): Benchmark => ({ + name: `up depth ${depth} ${placement.label}`, + operation: upOperation(String(chains)), + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { ...upBest, on: placement.on }, + run: () => measured(placement, async world => { + await world.run(forest, { roots: Number(roots), depth: Number(depth) }); + return fastest(() => world.run(propagate)); + }), + })), + ]), + ...placements.map((placement): Benchmark => ({ + name: `sync point grow 100 archetypes ${placement.label}`, + operation: placement.workers === 0 + ? "A sync point where each of 100 archetypes grows, by one spawn into each: levels and bursts filling many kinds of entities at once." + : "A sync point where each of 100 archetypes grows, by one spawn into each, then a kernel over them, so workers take up the new layout.", + per: "archetypes grown", + n: 100, + typical: [1, 100], + best: { bytes: 64, basis: "Each archetype that grows writes at least its layout record, a cache line.", on: placement.on }, + run: async () => { + let best = Infinity; + for (let round = 0; round < rounds; round++) { + const world = createWorld({ [workers]: placement.workers }, { minimumChunk: 16 }); + try { + // Registers both systems and loads the kernel; then every archetype holds one row of its 16. + await world.run(spawnEach); + await world.run(bumpEach); + let count = 1; + for (let capacity = 16, d = 0; d < doublings; d++, capacity *= 2) { + await world.run(fillEach, { count: capacity - count }); + const start = performance.now(); + await world.run(spawnEach); + if (placement.workers > 0) await world.run(bumpEach); + best = Math.min(best, performance.now() - start); + count = capacity + 1; + } + } finally { + await world.dispose(); + } + } + return best; + }, + })), +]; diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts new file mode 100644 index 000000000..59302ac72 --- /dev/null +++ b/packages/ecs/src/perftest/reads.ts @@ -0,0 +1,210 @@ +import { Component } from "../interface/component/component.js"; +import type { Entity } from "../interface/entity/entity.js"; +import { Resource } from "../interface/resource/resource.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// Benchmarks of reading a world from outside it: runs of systems that copy values out, and lookups by entity. The +// observer factories' are in `observed.ts`. + +const level = Symbol("reads/level"); +const value = Symbol("reads/value"); +const rank = Symbol("reads/rank"); +const marked = Symbol("reads/marked"); +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [rank]: { type: number }; + [marked]: { type: true }; + } + export interface Resources { + [level]: { type: number }; + } +} +Resource.register(level, { type: { type: "number", default: 3 } }); +Component.register(value, { type: { type: "number" } }); +Component.register(rank, { type: { type: "number" } }); +Component.register(marked, { type: { const: true } }); + +/** `count` entities with a value and a rank, the first of them marked. */ +const seed = System.create({ name: "reads/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); + for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); +}); + +/** Writes the marked entity's value. */ +const nudge = System.create({ name: "reads/nudge", components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); + +/** A kernel writing every row's rank, whose stamps it keeps only while something watches ranks change. */ +const advance = System.create({ name: "reads/advance", kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); + +/** The entities `look` and `lookChanged` look up, each run. */ +let looked: readonly Entity[] = []; +let found = 0; + +/** Looks up every entity of `looked` by entity, reading its value, after `nudge` where both run. */ +const look = System.create({ name: "reads/look", lookup: { values: { read: [value] } }, after: [nudge] }, ({ lookup }) => { + for (let i = 0; i < looked.length; i++) { + const row = lookup.values(looked[i]); + if (row !== null && row[value] >= 0) found++; + } +}); + +/** The same, with `changed`: counts the entities whose value may have changed since it last ran. */ +const lookChanged = System.create({ name: "reads/lookChanged", lookup: { values: { read: [value], changed: [value] } }, after: [nudge] }, ({ lookup }) => { + for (let i = 0; i < looked.length; i++) { + const row = lookup.values(looked[i]); + if (row !== null && row[value] >= 0 && row.changed) found++; + } +}); + +const nudgeThenLook = System.create({ name: "reads/nudgeThenLook", systems: [nudge, look] }); +const nudgeThenLookChanged = System.create({ name: "reads/nudgeThenLookChanged", systems: [nudge, lookChanged] }); + +/** A run of `system` over a world of `observedEntities` entities `looked` names, `found` after the last. */ +const lookups = async (system: System): Promise<{ readonly best: number; readonly found: number }> => { + const world = await seeded(observedEntities); + looked = await entitiesOf(world); + await world.run(system); + found = 0; + const best = await fastest(() => world.run(system)); + found = 0; + await world.run(system); + const result = { best, found }; + looked = []; + await world.dispose(); + return result; +}; + +/** Every entity a seeded world holds. */ +const entitiesOf = async (world: World): Promise => { + const found: Entity[] = []; + await world.run(System.create({ observer: true, components: { has: [value] } }, row => { found.push(row.entity); })); + return found; +}; + +const seeded = async (count: number): Promise => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { count }); + return world; +}; + +/** Entities the lookup benchmarks look up. */ +const observedEntities = 1_000; + +let copied = 0; + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +export const readBenchmarks: readonly Benchmark[] = [ + { + name: "read run", + operation: "Reading a resource through a run of a new system that copies it out: a value read from outside the world through a system made for the read.", + per: "reads", + n: 1, + typical: [1, 100], + best: { bytes: 64, basis: "A read loads at least one cache line of the resource's cell." }, + run: async () => { + const world = await seeded(10_000); + copied = 0; + const best = await fastest(() => world.run(System.create({ resources: { read: [level] } }, ({ resources }) => { copied = resources[level]; })), 300); + check(copied === 3, "read run: wrong value"); + await world.dispose(); + return best; + }, + }, + { + name: "read run observer", + operation: "Reading a resource through a run of a new observer system that copies it out, which has no tiers and an empty sync point: as `read run`, through an observer system.", + per: "reads", + n: 1, + typical: [1, 100], + best: { bytes: 64, basis: "A read loads at least one cache line of the resource's cell." }, + run: async () => { + const world = await seeded(10_000); + copied = 0; + const best = await fastest(() => world.run(System.create({ observer: true, resources: { read: [level] } }, ({ resources }) => { copied = resources[level]; })), 300); + check(copied === 3, "read run observer: wrong value"); + await world.dispose(); + return best; + }, + }, + { + name: "read run changed", + operation: "Reading the ranks that changed through a run of a new system filtering on `changed`, with a kernel writing them: a read whose filter turns the kernel's stamps on while the system lives.", + per: "reads", + n: 1, + typical: [1, 100], + best: { bytes: 64, basis: "A read loads at least one cache line of a chunk's tick." }, + run: async () => { + // Few rows, since a new system's first run reads every row. + const world = await seeded(100); + await world.run(advance); + copied = 0; + const best = await fastest(async () => { + await world.run(System.create({ components: { read: [rank], changed: [rank] } }, row => { copied += row[rank]; })); + }, 300); + check(copied > 0, "read run changed: nothing read"); + await world.dispose(); + return best; + }, + }, + { + name: "lookup 1K entities", + operation: "A system looking up 1K entities by entity, reading a value of each, with nothing changed: following references, without `changed`.", + per: "lookups", + n: observedEntities, + typical: [10, 1_000], + best: { bytes: 16, basis: "Each lookup reads its entity's 8-byte location and its 8-byte value." }, + run: async () => { + const { best, found } = await lookups(look); + check(found === observedEntities, "lookup 1K entities: wrong count"); + return best; + }, + }, + { + name: "lookup 1K entities changed", + operation: "The same with `changed`, with nothing changed: watching entities a system follows for changes since it last ran.", + per: "lookups", + n: observedEntities, + typical: [10, 1_000], + best: { bytes: 24, basis: "Each lookup reads its entity's 8-byte location, its 8-byte value and its 8-byte changed tick." }, + run: async () => { + const { best, found } = await lookups(lookChanged); + check(found === 0, "lookup 1K entities changed: changed"); + return best; + }, + }, + { + name: "lookup 1K entities one changed", + operation: "A run writing one of 1K entities, then a system looking up each by entity, reading its value, without `changed`.", + per: "lookups", + n: observedEntities, + typical: [10, 1_000], + best: { bytes: 16, basis: "Each lookup reads its entity's 8-byte location and its 8-byte value." }, + run: async () => { + const { best, found } = await lookups(nudgeThenLook); + check(found === observedEntities, "lookup 1K entities one changed: wrong count"); + return best; + }, + }, + { + name: "lookup 1K entities changed one changed", + operation: "The same with `changed`: one of the entities a system follows changed since it last ran.", + per: "lookups", + n: observedEntities, + typical: [10, 1_000], + best: { bytes: 24, basis: "Each lookup reads its entity's 8-byte location, its 8-byte value and its 8-byte changed tick." }, + run: async () => { + const { best, found } = await lookups(nudgeThenLookChanged); + check(found === 1, "lookup 1K entities changed one changed: wrong count"); + return best; + }, + }, +]; diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts new file mode 100644 index 000000000..f975e3484 --- /dev/null +++ b/packages/ecs/src/perftest/remote.ts @@ -0,0 +1,364 @@ +/// +import { Component } from "../interface/component/component.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { Resource } from "../interface/resource/resource.js"; +import { always } from "../interface/resources/always.js"; +import { eachTick } from "./observer.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { backToBack, createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; + +// Benchmarks of a thread joined to a world: its runs, and what it reads after each sync point, through observer systems +// it adds and the observer factories. The joined thread is `joined.ts`. + +export const counter = Symbol("remote/counter"); +export const sign = Symbol("remote/sign"); +export const rank = Symbol("remote/rank"); +export const order = Symbol("remote/order"); +export const caption = Symbol("remote/caption"); +export const amount = Symbol("remote/amount"); +declare module "@adobe/ecs" { + export interface Components { + [rank]: { type: number }; + [order]: { type: number }; + [caption]: { type: string }; + [amount]: { type: number }; + } + export interface Resources { + [counter]: { type: number }; + [sign]: { type: number }; + } +} +Resource.register(counter, { type: { type: "number", default: 0 } }); +Resource.register(sign, { type: { type: "number", default: 1 } }); +Component.register(rank, { type: { type: "number" } }); +Component.register(order, { type: { type: "number" } }); +Component.register(caption, { type: { type: "string" } }); +Component.register(amount, { type: { type: "number" } }); + +/** An empty transaction. */ +export const empty = System.create({ name: "remote/empty" }, () => {}); +/** Changes the observed resource every tick. */ +export const count = System.create({ name: "remote/count", resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); +export const seedRanks = System.create({ name: "remote/seedRanks", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn([rank, order])({ [rank]: i, [order]: i }); +}); +const flip = System.create({ name: "remote/flip", resources: { write: [sign] } }, ({ resources }) => { resources[sign] = -resources[sign]; }); +const reverse = System.create({ name: "remote/reverse", kernel: "cpu", components: { read: [rank], write: [order] }, resources: { read: [sign] }, after: [flip] }, (row, { resources }) => { + row[order] = row[rank] * resources[sign]; +}); +/** Reverses the entities' order every tick. */ +export const reorder = System.create([flip, reverse]); + +/** What a joined thread's observer systems saw: the last value, and how many runs gave one. */ +export const observerSeen = { last: 0, notified: 0 }; + +/** On a joined thread, after each sync point that changed it: the observed resource. */ +export const observerCounter = System.create({ name: "remote/observerCounter", observer: true, resources: { read: [counter], changed: [counter] } }, ({ resources }) => { + observerSeen.last = resources[counter]; + observerSeen.notified++; +}); + +/** Every entity with a rank, in entity order. */ +const ranked = Index.create({ name: "remote/ranked", key: [], components: { has: [rank, order] } }); +const sortedKeys = { keys: new Float64Array(0), entities: new Float64Array(0), positions: new Uint32Array(0) }; +/** + * On a joined thread, after each sync point: the entities in `order`, sorted from a scan of the index of every ranked + * entity into reused arrays. + */ +export const observerSelect = System.create({ name: "remote/observerSelect", observer: true, lookup: { all: { index: ranked, read: [order] } } }, ({ lookup }) => { + const list = lookup.all(); + const count = list.length; + if (sortedKeys.keys.length < count) { + sortedKeys.keys = new Float64Array(count); + sortedKeys.entities = new Float64Array(count); + sortedKeys.positions = new Uint32Array(count); + } + const { keys, entities } = sortedKeys; + for (let k = 0; k < count; k++) { + const row = list.at(k); + if (row === null) continue; + keys[k] = row[order]; + entities[k] = row.entity; + } + const positions = sortedKeys.positions.subarray(0, count); + for (let k = 0; k < count; k++) positions[k] = k; + positions.sort((a, b) => keys[a] - keys[b] || entities[a] - entities[b]); + observerSeen.last = count; + observerSeen.notified++; +}); + +/** Rows with a caption and an amount, a todo's label and a number, which `retext` and `reamount` rewrite 1% of a tick. */ +export const seedCaptions = System.create({ name: "remote/seedCaptions", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn([rank, caption, amount])({ [rank]: i, [caption]: `Buy milk and bread #${i}`, [amount]: i }); +}); +const recaption = System.create({ name: "remote/recaption", components: { read: [rank], write: [caption] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { + if (row[rank] % 100 === resources[counter] % 100) row[caption] = `Buy milk and bread #${row[rank]}.${resources[counter]}`; +}); +const reamount = System.create({ name: "remote/reamount", components: { read: [rank], write: [amount] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { + if (row[rank] % 100 === resources[counter] % 100) row[amount] = row[rank] + resources[counter]; +}); +/** Rewrites 1% of the captions a tick. */ +export const retext = System.create([count, recaption]); +/** Rewrites 1% of the amounts a tick. */ +export const reamounted = System.create([count, reamount]); + +/** On a joined thread, after each sync point: every row's caption, out of its record. */ +export const observerCaptions = System.create({ name: "remote/observerCaptions", observer: true, components: { read: [caption] } }, row => { + observerSeen.last += row[caption].length; + observerSeen.notified++; +}); +/** On a joined thread, after each sync point: every row's amount. */ +export const observerAmounts = System.create({ name: "remote/observerAmounts", observer: true, components: { read: [amount] } }, row => { + observerSeen.last += row[amount]; + observerSeen.notified++; +}); + +/** Runs in each timed stretch of `remote run`, and ticks in each of the joined benchmarks'. */ +export const remoteRuns = 1_000; +export const joinedTicks = 1_000; +/** Entities `joined observer 10K` selects. */ +export const selected = 10_000; + +/** What a joined thread reads in a joined benchmark: through an observer system, or the observer factories. */ +type Observed = "observer resource" | "observer select" | "observer captions" | "observer amounts" | "factory resource" | "factory select"; + +/** What the joined thread is asked to do, and what it answers. */ +export type ToJoined = { readonly kind: "runs"; readonly stretches: number } | { readonly kind: Observed } | { readonly kind: "stop" }; +export type FromJoined = { readonly kind: "ran"; readonly milliseconds: number } | { readonly kind: "observing" } | { readonly kind: "stopped"; readonly last: number; readonly notified: number }; + +type Joined = { readonly ask: (message: ToJoined) => Promise; readonly terminate: () => Promise }; + +const joinedFile = "./joined.js"; + +/** The observed resource, read by an observer run. */ +const counterOf = async (world: World): Promise => { + let value = 0; + await world.run(System.create({ observer: true, resources: { read: [counter] } }, ({ resources }) => { value = resources[counter]; })); + return value; +}; + +/** Starts `joined.ts` on a worker, joining `world` through a channel it's given. */ +const startJoined = async (world: World): Promise => { + if (typeof globalThis.Worker === "function") { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = new globalThis.Worker(new URL("./joined.js", import.meta.url), { type: "module" }); + const ask = (message: unknown, transfer: Transferable[] = []): Promise => new Promise(resolve => { + worker.onmessage = (event: MessageEvent) => resolve(event.data); + worker.postMessage(message, transfer); + }); + await ask({ kind: "port", port: port2 }, [port2]); + return { ask, terminate: async () => worker.terminate() }; + } + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const { port1, port2 } = new threads.MessageChannel(); + world.share(port1); + const worker = new threads.Worker(new URL(joinedFile, import.meta.url)); + const ask = (message: unknown, transfer: readonly import("node:worker_threads").Transferable[] = []): Promise => new Promise(resolve => { + worker.once("message", resolve); + worker.postMessage(message, transfer); + }); + await ask({ kind: "port", port: port2 }, [port2]); + return { ask, terminate: async () => { await worker.terminate(); } }; +}; + +/** The fastest of `stretches` stretches of `remoteRuns` empty runs from a joined thread, timed there. */ +const remoteRun = async (stretches = 5): Promise => { + const world = createWorld({ [workers]: 0 }); + const joined = await startJoined(world); + try { + const reply = await joined.ask({ kind: "runs", stretches }); + return reply.kind === "ran" ? reply.milliseconds : NaN; + } + finally { + await joined.terminate(); + await world.dispose(); + } +}; + +/** + * The fastest of `stretches` stretches of `joinedTicks` ticks on the world's thread, driven back to back, of `system` + * while a joined thread reads what it changes, after one to warm up. + */ +const joinedBest = async (kind: Observed, system: System, stretches = 5): Promise => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const reads = kind.endsWith("resource"); + const rows = kind === "observer captions" || kind === "observer amounts"; + if (rows) await world.run(seedCaptions, { count: selected }); + else if (!reads) await world.run(seedRanks, { count: selected }); + const joined = await startJoined(world); + let best = Infinity; + try { + await joined.ask({ kind }); + await new Promise(resolve => { + let ticks = 0; + let stretch = 0; + let start = 0; + const unobserve = eachTick(world, () => { + if (ticks++ % joinedTicks !== 0) return; + const now = performance.now(); + if (stretch > 1) best = Math.min(best, now - start); + start = now; + if (stretch++ <= stretches) return; + unobserve(); + resolve(); + }); + world.add(system); + }); + const counted = reads ? await counterOf(world) : 0; + const stopped = await joined.ask({ kind: "stop" }); + if (stopped.kind !== "stopped" || stopped.notified === 0) throw new Error(`joined ${kind}: the joined thread saw nothing.`); + // Timed only while the joined thread keeps up: one that stopped being woken costs the world nothing. + if (reads && stopped.last < counted - joinedTicks) throw new Error(`joined ${kind}: the joined thread saw ${stopped.last} of ${counted}.`); + } + finally { + await joined.terminate(); + await world.dispose(); + driver.close(); + } + return best; +}; + +/** + * Ticks the cadence benchmarks give their time for, the ticks they time, after as many to warm up, and the milliseconds + * between their frames. + */ +export const cadenceTicks = 100; +const cadenceSamples = 1_000; +const cadenceMilliseconds = 2; + +/** + * Frames `milliseconds` apart, as a display's are. The loop asks for the next frame once a tick is done, so each ask + * after a frame began tells `ended` how long that frame's tick took. + */ +const spaced = (milliseconds: number, ended: (milliseconds: number) => void) => { + let began = -1; + return { + frame: (callback: (time: number) => void): (() => void) => { + if (began >= 0) ended(performance.now() - began); + began = -1; + const timer = setTimeout(() => { + began = performance.now(); + callback(began); + }, milliseconds); + return () => clearTimeout(timer); + }, + }; +}; + +/** + * What the world's thread spends on `cadenceTicks` ticks of `system`, from each frame's start until its tick is done, + * with frames far enough apart that a joined thread observing what it changes keeps up: the cost of `kind` at a + * display's cadence rather than back to back. A tick's time varies with what else the machine runs, a few ticks taking + * many times the rest, so it's the mean of the middle half of `cadenceSamples` ticks, about their median, rather than + * the least; and a mean rather than the median itself, as chromium's timer steps are as long as a tick. + */ +const cadenceMiddle = async (kind: Observed, system: System): Promise => { + const spent = new Float64Array(cadenceSamples); + let ticks = 0; + let finished = (): void => {}; + const done = new Promise(resolve => { finished = resolve; }); + const driver = spaced(cadenceMilliseconds, milliseconds => { + const tick = ticks++ - cadenceSamples; + if (tick < 0 || tick >= cadenceSamples) return; + spent[tick] = milliseconds; + if (tick === cadenceSamples - 1) finished(); + }); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const joined = await startJoined(world); + try { + await joined.ask({ kind }); + world.add(system); + await done; + const counted = await counterOf(world); + const stopped = await joined.ask({ kind: "stop" }); + if (stopped.kind !== "stopped" || stopped.last < counted - 2) throw new Error(`${kind} at a cadence: the joined thread saw ${stopped.kind === "stopped" ? stopped.last : "nothing"} of ${counted}.`); + } + finally { + await joined.terminate(); + await world.dispose(); + } + spent.sort(); + let middle = 0; + for (let k = cadenceSamples >> 2; k < cadenceSamples - (cadenceSamples >> 2); k++) middle += spent[k]; + return cadenceTicks * middle / (cadenceSamples >> 1); +}; + +export const remoteBenchmarks: readonly Benchmark[] = [ + { + name: "remote run", + operation: "An empty transaction run from a joined thread, through its run slots and completed word: input from another thread, such as the DOM on main driving a world on a worker.", + per: "runs", + n: remoteRuns, + typical: [1, 100], + best: { bytes: 192, basis: "A run moves at least its slot's line and the posted and completed words' lines between cores." }, + run: () => remoteRun(), + }, + { + name: "joined observer resource", + operation: "Ticks that change one resource an observer system a joined thread added reads after each sync point, posted to it and waking it once.", + per: "ticks", + n: joinedTicks, + typical: [1, 1], + best: { bytes: 64, basis: "A tick reads at least one cache line of its system's state." }, + run: () => joinedBest("observer resource", count), + }, + { + name: "joined observer 10K", + operation: "Ticks that reverse the order of 10K entities an observer system a joined thread added reads through an index and sorts after each sync point.", + per: "ticks", + n: joinedTicks, + typical: [1, 1], + best: { bytes: selected * 24, basis: "Each tick writes 10K 8-byte values, reads them to sort, and sorts 10K 8-byte entities." }, + run: () => joinedBest("observer select", reorder, 3), + }, + { + name: "joined observer strings 10K", + operation: "Ticks that rewrite 1% of 10K captions, a todo's label each, while an observer system a joined thread added reads every caption after each sync point, out of its record: a binding on main reading a world on a worker.", + per: "ticks", + n: joinedTicks, + typical: [1, 1], + best: { bytes: 40_000, basis: "A pass reads at least each row's 4-byte handle." }, + run: () => joinedBest("observer captions", retext, 3), + }, + { + name: "joined observer numbers 10K", + operation: "The same with 10K numbers in place of the captions, as `joined observer strings 10K` measures against.", + per: "ticks", + n: joinedTicks, + typical: [1, 1], + best: { bytes: 80_000, basis: "A pass reads at least each row's 8-byte number." }, + run: () => joinedBest("observer amounts", reamounted, 3), + }, + { + name: "joined resource factory", + operation: "Ticks that change one resource a joined thread observes through `Observe.resource`, an observer system it added, posted to it and waking it once.", + per: "ticks", + n: joinedTicks, + typical: [1, 1], + best: { bytes: 64, basis: "A tick reads at least one cache line of its system's state." }, + run: () => joinedBest("factory resource", count), + }, + { + name: "joined select 10K factory", + operation: "Ticks that reverse the order of 10K entities a joined thread selects in order through `Observe.select`.", + per: "ticks", + n: joinedTicks, + typical: [1, 1], + best: { bytes: selected * 24, basis: "Each tick writes 10K 8-byte values, reorders them in the index, and reads 10K 8-byte entities." }, + run: () => joinedBest("factory select", reorder, 3), + }, + { + name: "joined resource factory cadence", + operation: "`joined resource factory` at a display's cadence, frames 2 ms apart, the joined thread keeping up: what the world's thread spends on a tick, from its frame until the tick is done.", + per: "ticks", + n: cadenceTicks, + typical: [1, 1], + best: { bytes: 64, basis: "A tick reads at least one cache line of its system's state." }, + run: () => cadenceMiddle("factory resource", count), + }, +]; diff --git a/packages/ecs/src/perftest/run.ts b/packages/ecs/src/perftest/run.ts new file mode 100644 index 000000000..1f8a1e64d --- /dev/null +++ b/packages/ecs/src/perftest/run.ts @@ -0,0 +1,43 @@ +/// +import { calibrationSource, copyBandwidth, environmentLine, resultLine, threadsBandwidth, type Curve } from "./benchmark.js"; +import { benchmarks } from "./benchmarks.js"; +import { runKeyReading } from "./keys.js"; +import { threads } from "./placed.js"; + +/** Every worker's copy bandwidth together, through `worker_threads` running the calibration script over `parentPort`. */ +const nodeThreadsBandwidth = (): Promise => { + const { Worker } = process.getBuiltinModule("node:worker_threads"); + const scope = `const { parentPort } = require("node:worker_threads"); + const postMessage = message => parentPort.postMessage(message); + let onmessage = null; + parentPort.on("message", data => onmessage({ data }));`; + return threadsBandwidth(threads, () => { + const worker = new Worker(`${scope}\n${calibrationSource}`, { eval: true }); + return { + ask: message => new Promise(resolve => { + worker.once("message", resolve); + worker.postMessage(message); + }), + terminate: () => void worker.terminate(), + }; + }); +}; + +/** + * Runs benchmarks bundled, on plain node: real module bindings, as in production. `scripts/perf.mjs` reads its lines. + * - `--names` prints the benchmarks' names, one per line. + * - `--environment` prints the environment line, with measured copy bandwidth, of one thread and of every worker. + * - `--keys` prints the key-reading microbenchmark (`keys.ts`). + * - Otherwise it runs the benchmark with the given name, or those whose names match a regular expression with `--match`. + */ +const [option, value] = process.argv.slice(2); +if (option === "--names") for (const { name } of benchmarks) console.log(name); +else if (option === "--keys") runKeyReading(); +else if (option === "--environment") console.log(environmentLine(`node ${process.versions.node}`, { thread: copyBandwidth(), threads: await nodeThreadsBandwidth() })); +else { + const pattern = option === "--match" && value !== undefined ? new RegExp(value) : null; + for (const benchmark of benchmarks) { + if (pattern === null ? benchmark.name !== option : !pattern.test(benchmark.name)) continue; + console.log(resultLine(benchmark, await benchmark.run())); + } +} diff --git a/packages/ecs/src/perftest/strings.ts b/packages/ecs/src/perftest/strings.ts new file mode 100644 index 000000000..34f439ffb --- /dev/null +++ b/packages/ecs/src/perftest/strings.ts @@ -0,0 +1,133 @@ +import { Component } from "../interface/component/component.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld, join } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// Benchmarks of strings, which the world's thread holds as values, and keeps records of in shared memory while a joined +// thread may read them, against the same text held as objects, as every string was before. + +const text = Symbol("strings/text"); +const boxed = Symbol("strings/boxed"); +declare module "@adobe/ecs" { + export interface Components { + [text]: { type: string }; + [boxed]: { type: { readonly text: string } }; + } +} +Component.register(text, { type: { type: "string" } }); +Component.register(boxed, { type: { type: "object", properties: { text: { type: "string" } } } }); + +const rows = 10_000; + +/** A todo's label, of a typical length, different per row and round. */ +const labelOf = (row: number, round: number): string => `Buy milk and bread #${row}.${round}`; + +const seed = System.create({ name: "strings/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn([text, boxed])({ [text]: labelOf(i, 0), [boxed]: { text: labelOf(i, 0) } }); +}); + +let total = 0; +let round = 0; + +const readText = System.create({ name: "strings/readText", components: { read: [text] } }, row => { total += row[text].length; }); +const readBoxed = System.create({ name: "strings/readBoxed", components: { read: [boxed] } }, row => { total += row[boxed].text.length; }); +const writeText = System.create({ name: "strings/writeText", components: { write: [text] } }, row => { row[text] = labelOf(row.entity, round); }); +const writeBoxed = System.create({ name: "strings/writeBoxed", components: { write: [boxed] } }, row => { row[boxed] = { text: labelOf(row.entity, round) }; }); + +/** A seeded world, with a joined thread on this one when `shared`, which keeps records of its strings. */ +const seeded = async (shared = false): Promise<{ readonly world: World; readonly dispose: () => Promise }> => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + if (shared) world.share(channel.port1); + const remote = shared ? join(channel.port2, { threads: [] }) : null; + await world.run(seed, { count: rows }); + return { + world, + dispose: async () => { + await remote?.dispose(); + await world.dispose(); + }, + }; +}; + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +/** The fastest run of `system` over the seeded rows, each run a new round of text, with a joined thread when `shared`. */ +const measure = async (system: System, expected: (world: World) => Promise, shared = false): Promise => { + const { world, dispose } = await seeded(shared); + const best = await fastest(() => { + round++; + return world.run(system); + }); + await expected(world); + await dispose(); + return best; +}; + +const read = (system: System, name: string) => measure(system, async world => { + total = 0; + await world.run(system); + check(total > rows * 20, `${name}: wrong lengths`); +}); + +/** Each row's text, as the last write left it. */ +const written = (name: string) => async (world: World): Promise => { + let wrong = 0; + await world.run(System.create({ observer: true, components: { read: [text, boxed] } }, row => { + if (row[text] !== row[boxed].text && row[text] !== labelOf(row.entity, round) && row[boxed].text !== labelOf(row.entity, round)) wrong++; + })); + check(wrong === 0, `${name}: wrong text`); +}; + +export const stringBenchmarks: readonly Benchmark[] = [ + { + name: "string read 10K", + operation: "A main-thread system reading a string of every row, as a binding writing labels to the DOM does: the value the world's thread holds.", + per: "rows", + n: rows, + typical: [100, 100_000], + best: { bytes: 8, basis: "A row's reference to its string, in a JS array." }, + run: () => read(readText, "string read 10K"), + }, + { + name: "object read 10K", + operation: "The same text held as an object of the world's thread, read the same way, as strings were held before they lived in shared memory.", + per: "rows", + n: rows, + typical: [100, 100_000], + best: { bytes: 8, basis: "A row's reference to its object, in a JS array." }, + run: () => read(readBoxed, "object read 10K"), + }, + { + name: "string write 10K", + operation: "A main-thread system writing a new string to every row, as editing text does, with no joined thread: the world's thread holds its value, and keeps no record.", + per: "rows", + n: rows, + typical: [1, 10_000], + best: { bytes: 8, basis: "A row's reference to its string, in a JS array." }, + run: () => measure(writeText, written("string write 10K")), + }, + { + name: "string write 10K joined", + operation: "The same writes while a joined thread may read strings, so each writes a new record of its text in shared memory and lets the old one go, freed two sync points on.", + per: "rows", + n: rows, + typical: [1, 10_000], + best: { bytes: 76, basis: "A record's 8-byte header and its 30 code units, and the row's 4-byte handle." }, + run: () => measure(writeText, written("string write 10K joined"), true), + }, + { + name: "object write 10K", + operation: "The same text written as a new object of the world's thread to every row, as strings were held before.", + per: "rows", + n: rows, + typical: [1, 10_000], + best: { bytes: 8, basis: "A row's reference to its new object, in a JS array." }, + run: () => measure(writeBoxed, written("object write 10K")), + }, +]; diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts new file mode 100644 index 000000000..70de131b0 --- /dev/null +++ b/packages/ecs/src/perftest/structural.ts @@ -0,0 +1,164 @@ +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import type { Entity } from "../interface/entity/entity.js"; +import { System } from "../interface/system/system.js"; + +// Structural changes one kind at a time: spawns, despawns, adds and removes, through main-thread systems and kernels, +// each applied by the sync point that ends its run. + +export const index = Symbol("structural/index"); +export const value = Symbol("structural/value"); +export const flag = Symbol("structural/flag"); +export const amount = Symbol("structural/amount"); +export const extra = Symbol("structural/extra"); +export const spawned = Symbol("structural/spawned"); +const source1 = Symbol("structural/source1"); +const source2 = Symbol("structural/source2"); +const t0 = Symbol("structural/t0"); +const t1 = Symbol("structural/t1"); +const t2 = Symbol("structural/t2"); +const t3 = Symbol("structural/t3"); + +declare module "@adobe/ecs" { + export interface Components { + [index]: { type: number }; + [value]: { type: number }; + [flag]: { type: true }; + [amount]: { type: number }; + [extra]: { type: number }; + [spawned]: { type: true }; + [source1]: { type: true }; + [source2]: { type: true }; + [t0]: { type: true }; + [t1]: { type: true }; + [t2]: { type: true }; + [t3]: { type: true }; + } +} + +Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(value, { type: { type: "number", precision: 1 } }); +Component.register(amount, { type: { type: "number", precision: 1 } }); +Component.register(extra, { type: { type: "number", precision: 1 } }); +Component.register(flag, { type: { const: true } }); +Component.register(spawned, { type: { const: true } }); +Component.register(source1, { type: { const: true } }); +Component.register(source2, { type: { const: true } }); +Component.register(t0, { type: { const: true } }); +Component.register(t1, { type: { const: true } }); +Component.register(t2, { type: { const: true } }); +Component.register(t3, { type: { const: true } }); + +const seeded = [ + Archetype.create([index, value]), + Archetype.create([index, value, source1]), + Archetype.create([index, value, source2]), + Archetype.create([index, value, source1, source2]), +] as const; +const made = Archetype.create([index, value, spawned]); +/** Every set of the four tags `t0` to `t3`, by its bits. */ +const tags = [t0, t1, t2, t3] as const; +const sixteen = Array.from({ length: 16 }, (_, bits) => Archetype.create([index, value, spawned, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); +const several = Archetype.create([flag, amount, extra]); + +const counted = { type: "object", properties: { count: { type: "integer" } } } as const; + +/** `count` entities of one archetype, or with `sources: 4`, interleaved over four by two source tags. */ +export const seed = System.create({ + name: "structural/seed", + input: { type: "object", properties: { count: { type: "integer" }, sources: { type: "integer" } } }, +}, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) { + commands.spawn(seeded[input.sources === 1 ? 0 : i % 4])({ [index]: i, [value]: i }); + } +}); + +/** `count` spawns of one archetype. */ +export const spawnBulk = System.create({ name: "structural/spawnBulk", input: counted }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn(made)({ [index]: i, [value]: i }); +}); + +/** One spawn of each of four archetypes: a run's fixed cost per spawn. */ +export const spawnSingles = System.create({ name: "structural/spawnSingles" }, ({ commands }) => { + commands.spawn(made)({ [index]: 0, [value]: 0 }); + commands.spawn(sixteen[1])({ [index]: 1, [value]: 1 }); + commands.spawn(sixteen[2])({ [index]: 2, [value]: 2 }); + commands.spawn(sixteen[3])({ [index]: 3, [value]: 3 }); +}); + +/** `count` spawns over sixteen archetypes, entity i into archetype i % 16: more component sets than spawns cache. */ +export const spawnSixteen = System.create({ name: "structural/spawnSixteen", input: counted }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) { + commands.spawn(sixteen[i % 16])({ [index]: i, [value]: i }); + } +}); + +/** A spawn per row, from a kernel. */ +export const spawnKernel = System.create({ name: "structural/spawnKernel", kernel: "cpu", components: { read: [index], not: [spawned] } }, (row, { commands }) => { + commands.spawn(made)({ [index]: row[index], [value]: 0 }); +}); + +/** Despawns every entity a spawn benchmark made. */ +export const despawnSpawned = System.create({ name: "structural/despawnSpawned", components: { has: [spawned] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); + +/** Despawns every seeded entity. */ +export const despawnAll = System.create({ name: "structural/despawnAll", components: { has: [index], not: [spawned] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); + +/** The same, from a kernel. */ +export const despawnKernel = System.create({ name: "structural/despawnKernel", kernel: "cpu", components: { has: [index], not: [spawned] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); + +/** Entities `spawnOne` made, for `despawnOne`. */ +const pending: Entity[] = []; + +export const spawnOne = System.create({ name: "structural/spawnOne" }, ({ commands }) => { + pending.push(commands.spawn(made)({ [index]: 0, [value]: 0 })); +}); + +/** Despawns one entity: a run's fixed cost per despawn. */ +export const despawnOne = System.create({ name: "structural/despawnOne" }, ({ commands }) => { + const entity = pending.pop(); + if (entity !== undefined) commands.despawn(entity); +}); + +export const addFlag = System.create({ name: "structural/addFlag", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { + commands.add(row.entity, flag); +}); + +export const removeFlag = System.create({ name: "structural/removeFlag", components: { has: [flag] } }, (row, { commands }) => { + commands.remove(row.entity, flag); +}); + +export const addFlagKernel = System.create({ name: "structural/addFlagKernel", kernel: "cpu", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { + commands.add(row.entity, flag); +}); + +export const removeFlagKernel = System.create({ name: "structural/removeFlagKernel", kernel: "cpu", components: { has: [flag] } }, (row, { commands }) => { + commands.remove(row.entity, flag); +}); + +export const addAmount = System.create({ name: "structural/addAmount", components: { read: [index], not: [amount, spawned] } }, (row, { commands }) => { + commands.add(row.entity, amount, row[index]); +}); + +export const removeAmount = System.create({ name: "structural/removeAmount", components: { has: [amount] } }, (row, { commands }) => { + commands.remove(row.entity, amount); +}); + +/** Adds a value every row already has: no archetype moves, only values replaced and marked changed. */ +export const replaceAmount = System.create({ name: "structural/replaceAmount", components: { read: [amount] } }, (row, { commands }) => { + commands.add(row.entity, amount, row[amount] + 1); +}); + +export const addSeveral = System.create({ name: "structural/addSeveral", components: { read: [index], not: [flag, spawned] } }, (row, { commands }) => { + commands.add(row.entity, several, { [amount]: row[index], [extra]: 1 }); +}); + +export const removeSeveral = System.create({ name: "structural/removeSeveral", components: { has: [flag, amount, extra] } }, (row, { commands }) => { + commands.remove(row.entity, several); +}); diff --git a/packages/ecs/src/perftest/suite.perf-browser-test.ts b/packages/ecs/src/perftest/suite.perf-browser-test.ts new file mode 100644 index 000000000..0d06f880e --- /dev/null +++ b/packages/ecs/src/perftest/suite.perf-browser-test.ts @@ -0,0 +1,133 @@ +import { it } from "vitest"; +import { gpuDevice } from "../interface/resources/gpu-device.js"; +import { createWorld } from "../implementation/world/create.js"; +import { calibrationSource, copyBandwidth, environmentLine, resultLine, threadsBandwidth, type Benchmark, type Curve } from "./benchmark.js"; +import { gpuForest, place } from "./hierarchy.js"; +import { workers } from "../interface/resources/workers.js"; +import { loopTickIdle, loopTicks } from "./loop.js"; +import { main, placed, pooled, threads, upOperation } from "./placed.js"; +import { wake } from "./wake.js"; +import { remoteBenchmarks } from "./remote.js"; +import { logBenchmarks } from "./log.js"; +import { handoffBenchmarks } from "./handoff.js"; +import { finalizedBenchmarks } from "./finalized.js"; +import { tailBenchmarks } from "./tails.js"; +import { transferBenchmarks } from "./transfers.js"; +import { indexBenchmarks } from "./indexes.js"; +import { structReplace, valueBenchmarks } from "./values.js"; +import { observedGpuBenchmarks } from "./observed-gpu.js"; +import { uploadBenchmarks } from "./uploads.js"; + +/** Every worker's copy bandwidth together, through browser workers running the calibration script. */ +const browserThreadsBandwidth = async (): Promise => { + const url = URL.createObjectURL(new Blob([calibrationSource], { type: "text/javascript" })); + try { + return await threadsBandwidth(threads, () => { + const worker = new Worker(url); + return { + ask: message => new Promise(resolve => { + worker.onmessage = resolve; + worker.postMessage(message); + }), + terminate: () => worker.terminate(), + }; + }); + } + finally { + URL.revokeObjectURL(url); + } +}; + +const requestDevice = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +/** Bytes per nanosecond the GPU copies between buffers, read and written. */ +const gpuBandwidth = async (device: GPUDevice): Promise => { + const bytes = 1 << 27; + const from = device.createBuffer({ size: bytes, usage: GPUBufferUsage.COPY_SRC }); + const to = device.createBuffer({ size: bytes, usage: GPUBufferUsage.COPY_DST }); + const copies = 10; + let best = Infinity; + for (let i = 0; i < 6; i++) { + const encoder = device.createCommandEncoder(); + for (let k = 0; k < copies; k++) encoder.copyBufferToBuffer(from, 0, to, 0, bytes); + const start = performance.now(); + device.queue.submit([encoder.finish()]); + await device.queue.onSubmittedWorkDone(); + best = Math.min(best, performance.now() - start); + } + from.destroy(); + to.destroy(); + return (2 * bytes * copies) / (best * 1e6); +}; + +/** Milliseconds per run on the GPU: runs submitted back to back, timed until the GPU finishes them. */ +const gpuFastest = async (device: GPUDevice, run: () => Promise, batch = 20): Promise => { + for (let i = 0; i < batch; i++) await run(); + await device.queue.onSubmittedWorkDone(); + let best = Infinity; + for (let round = 0; round < 10; round++) { + const start = performance.now(); + for (let i = 0; i < batch; i++) await run(); + await device.queue.onSubmittedWorkDone(); + best = Math.min(best, (performance.now() - start) / batch); + } + return best; +}; + +const benchmarks = (device: GPUDevice): readonly Benchmark[] => [ + ...placed([main, pooled]), + ...indexBenchmarks.filter(benchmark => benchmark.name.endsWith(" workers")), + ...valueBenchmarks, + structReplace("main", 0), + structReplace("workers", threads, "threads"), + wake, + { + name: "loop tick idle", + operation: "A world's loop ticking with nothing changed, driven back to back with `always` set, counted by an observer system reading its clock: the fixed cost of every tick.", + per: "ticks", + n: loopTicks, + typical: [1, 1], + best: { bytes: 64, basis: "A run reads at least one cache line of its system's state." }, + run: () => loopTickIdle(), + }, + ...remoteBenchmarks, + ...logBenchmarks, + ...handoffBenchmarks, + ...finalizedBenchmarks, + ...tailBenchmarks, + ...transferBenchmarks, + ...observedGpuBenchmarks(device, run => gpuFastest(device, run)), + ...uploadBenchmarks(device, run => gpuFastest(device, run)), + ...[[100_000, 10, "100K chains of 10"], [1_000, 1_000, "1K chains of 1,000"]].map(([roots, depth, chains]): Benchmark => ({ + name: `up depth ${depth} gpu`, + operation: upOperation(String(chains)), + per: "rows", + n: 1_000_000, + typical: [1_000, 100_000], + best: { bytes: 16, basis: "Each row reads its 4-byte value, its parent's 4-byte result and 4-byte row, and writes its 4-byte result.", on: "gpu" }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(gpuForest, { roots: Number(roots), depth: Number(depth) }); + return await gpuFastest(device, () => world.run(place)); + } finally { + await world.dispose(); + } + }, + })), +]; + +it("runs the browser benchmarks", async () => { + const device = await requestDevice(); + console.log(environmentLine(navigator.userAgent.match(/Chrome\/[\d.]+/)?.[0].replace("/", " ") ?? navigator.userAgent, { + thread: copyBandwidth(), + threads: await browserThreadsBandwidth(), + gpu: await gpuBandwidth(device), + })); + for (const benchmark of benchmarks(device)) console.log(resultLine(benchmark, await benchmark.run())); + device.destroy(); +}, 600_000); diff --git a/packages/ecs/src/perftest/suite.perf-test.ts b/packages/ecs/src/perftest/suite.perf-test.ts new file mode 100644 index 000000000..ad8bb6d9a --- /dev/null +++ b/packages/ecs/src/perftest/suite.perf-test.ts @@ -0,0 +1,7 @@ +import { it } from "vitest"; +import { benchmarks } from "./benchmarks.js"; +import { report } from "./suite.js"; + +// Under vitest's node transform every imported binding is a getter, which inflates JS-heavy paths; +// `pnpm perf` runs the same benchmarks bundled, on plain node. +for (const benchmark of benchmarks) it(benchmark.name, async () => report(benchmark.name, await benchmark.run()), 120_000); diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts new file mode 100644 index 000000000..cec2c7412 --- /dev/null +++ b/packages/ecs/src/perftest/suite.ts @@ -0,0 +1,232 @@ +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import { Resource } from "../interface/resource/resource.js"; +import { Entity } from "../interface/entity/entity.js"; +import type { Schema } from "../interface/schema/schema.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; + +// One fixture per optimization area: structural changes, change filters, lookups, commands, dispatch and main-thread +// paths. + +type Vec3 = readonly [number, number, number]; +const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; + +export const position = Symbol("suite/position"); +export const velocity = Symbol("suite/velocity"); +export const target = Symbol("suite/target"); +export const index = Symbol("suite/index"); +export const tag = Symbol("suite/tag"); +export const unused = Symbol("suite/unused"); +export const hits = Symbol("suite/hits"); + +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: readonly [number, number, number] }; + [velocity]: { type: readonly [number, number, number] }; + [target]: { type: Entity }; + [index]: { type: number }; + [tag]: { type: true }; + [unused]: { type: true }; + [hits]: { type: number }; + } +} + +export const indexSum = Symbol("suite/indexSum"); +export const highest = Symbol("suite/highest"); +declare module "@adobe/ecs" { + export interface Resources { + [indexSum]: { type: number; reduce: "+" }; + [highest]: { type: number; reduce: "max" }; + } +} +Resource.register(indexSum, { type: { type: "number", default: 0 }, reduce: "+" }); +Resource.register(highest, { type: { type: "number", default: 0 }, reduce: "max" }); + +Component.register(position, { type: vec3 }); +Component.register(velocity, { type: vec3 }); +Component.register(target, { type: Entity.schema }); +Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(tag, { type: { const: true } }); +Component.register(unused, { type: { const: true } }); +Component.register(hits, { type: { type: "number", precision: 1 } }); + +const moving = Archetype.create([position, velocity, index]); +const following = Archetype.create([position, velocity, index, target]); + +/** `count` entities, each targeting the one spawned before it. */ +export const seed = System.create({ name: "suite/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + let previous = commands.spawn(moving)({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [index]: 0 }); + for (let i = 1; i < input.count; i++) { + previous = commands.spawn(following)({ [position]: [i, i * 0.5, -i], [velocity]: [1, 0, 0], [index]: i, [target]: previous }); + } +}); + +/** Despawns a tenth of the entities and spawns as many replacements. */ +export const churn = System.create({ name: "suite/churn", input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index, position] } }, (row, { input, commands }) => { + if (row[index] % 10 !== input.group) return; + commands.despawn(row.entity); + commands.spawn(moving)({ [position]: row[position], [velocity]: [0, 0, 0], [index]: row[index] }); +}); + +/** Adds the tag to one tenth and removes it from another: a fifth of the entities move archetype. */ +export const retag = System.create({ name: "suite/retag", input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index] } }, (row, { input, commands }) => { + const group = row[index] % 10; + if (group === input.group) commands.add(row.entity, tag); + else if (group === (input.group + 5) % 10) commands.remove(row.entity, tag); +}); + +/** Writes 1% of positions. */ +export const poke = System.create({ + name: "suite/poke", + kernel: "cpu", + input: { type: "object", properties: { group: { type: "integer" } } }, + components: { read: [index], write: [position] }, +}, (row, { input }) => { + if (row[index] % 100 === input.group) { + const [x, y, z] = row[position]; + row[position] = [x + 1, y, z]; + } +}); + +/** Writes 1% of positions, clustered in one contiguous run of rows. */ +export const pokeCluster = System.create({ + name: "suite/pokeCluster", + kernel: "cpu", + input: { type: "object", properties: { group: { type: "integer" } } }, + components: { read: [index], write: [position] }, +}, (row, { input }) => { + const group = row[index] - input.group * 10_000; + if (group >= 0 && group < 10_000) { + const [x, y, z] = row[position]; + row[position] = [x + 1, y, z]; + } +}); + +/** Sees only what changed since it last ran. */ +export const observe = System.create({ + name: "suite/observe", + kernel: "cpu", + components: { read: [position], write: [velocity], changed: [position] }, +}, row => { + const [x, y, z] = row[position]; + row[velocity] = [x * 0.5, y * 0.5, z * 0.5]; +}); + +/** Steers toward the target: one lookup per row. */ +export const follow = System.create({ + name: "suite/follow", + kernel: "cpu", + components: { read: [position, target], write: [velocity] }, + lookup: { targets: { read: [position] } }, +}, (row, { lookup: { targets } }) => { + const other = targets(row[target]); + if (other !== null) { + const [tx, ty, tz] = other[position]; + const [x, y, z] = row[position]; + row[velocity] = [tx - x, ty - y, tz - z]; + } +}); + +/** Issues commands from a kernel: a tenth of the rows record a hit. */ +export const emit = System.create({ + name: "suite/emit", + kernel: "cpu", + components: { read: [index, position] }, +}, (row, { commands }) => { + if (row[index] % 10 === 0) { + const [x] = row[position]; + commands.add(row.entity, hits, x); + } +}); + +/** Six dependent kernels: one tier each, so dispatch cost shows. */ +export const chain1 = System.create({ name: "suite/chain1", kernel: "cpu", components: { write: [velocity] } }, row => { + const [x, y, z] = row[velocity]; + row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; +}); +export const chain2 = System.create({ name: "suite/chain2", kernel: "cpu", components: { write: [velocity] }, after: [chain1] }, row => { + const [x, y, z] = row[velocity]; + row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; +}); +export const chain3 = System.create({ name: "suite/chain3", kernel: "cpu", components: { write: [velocity] }, after: [chain2] }, row => { + const [x, y, z] = row[velocity]; + row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; +}); +export const chain4 = System.create({ name: "suite/chain4", kernel: "cpu", components: { write: [velocity] }, after: [chain3] }, row => { + const [x, y, z] = row[velocity]; + row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; +}); +export const chain5 = System.create({ name: "suite/chain5", kernel: "cpu", components: { write: [velocity] }, after: [chain4] }, row => { + const [x, y, z] = row[velocity]; + row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; +}); +export const chain6 = System.create({ name: "suite/chain6", kernel: "cpu", components: { write: [velocity] }, after: [chain5] }, row => { + const [x, y, z] = row[velocity]; + row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; +}); +export const chain = System.create({ name: "suite/chain", systems: [chain1, chain2, chain3, chain4, chain5, chain6] }); + +/** Fifty small main-thread systems in one tier, each reading every row. */ +let total = 0; +export const smalls = System.create({ + name: "suite/smalls", + systems: Array.from({ length: 50 }, (_, i) => System.create({ name: `suite/small${i}`, components: { read: [position] } }, row => { + total += row[position][0]; + })), +}); +export const totalOf = (): number => total; + +/** Fastest of repeated runs within a time budget, like the adobe/data harness. */ +export const fastest = async (run: () => Promise, budget = 1000, warmup = 10): Promise => { + for (let warm = 0; warm < warmup; warm++) await run(); + let minimum = Infinity; + const end = performance.now() + budget; + while (performance.now() < end) { + const start = performance.now(); + await run(); + minimum = Math.min(minimum, performance.now() - start); + } + return minimum; +}; + +/** One line per benchmark, so runs can be compared with grep. */ +export const report = (name: string, milliseconds: number): void => { + console.log(`bench ${name}: ${milliseconds.toFixed(3)} ms`); +}; + +/** A world seeded with `count` entities. Systems register on their first run, which `fastest` leaves out. */ +export const seeded = async (create: () => World, count: number): Promise => { + const world = create(); + await world.run(seed, { count }); + return world; +}; + +/** Folds every row's index into a sum and a maximum. */ +export const reduce = System.create({ name: "suite/reduce", kernel: "cpu", components: { read: [index] }, reduce: { [indexSum]: "+", [highest]: "max" } }, (row, { reduce }) => { + reduce[indexSum](row[index]); + reduce[highest](row[index]); +}); + +/** A system no entity matches: what a run costs with nothing to do. */ +export const idle = System.create({ name: "suite/idle", components: { has: [unused] } }, () => {}); +/** A system that throws, so each run of it has a report to spawn. */ +export const failing = System.create({ name: "suite/failing" }, () => { throw new Error("suite: failing"); }); +/** An observer system no entity matches: a run of it alone has no tiers. */ +export const idleObserver = System.create({ name: "suite/idleObserver", observer: true, components: { has: [unused] } }, () => {}); + +/** Fifty systems no entity matches, in one tier. */ +export const idles = System.create({ + name: "suite/idles", + systems: Array.from({ length: 50 }, (_, i) => System.create({ name: `suite/idle${i}`, components: { has: [unused] } }, () => {})), +}); + +/** Spawns one entity: a run with a sync point that applies one command. */ +export const spawnOne = System.create({ name: "suite/spawnOne" }, ({ commands }) => { + commands.spawn(moving)({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [index]: 0 }); +}); + +/** One main-thread system reading every row. */ +export const small = System.create({ name: "suite/small", components: { read: [position] } }, row => { + total += row[position][0]; +}); diff --git a/packages/ecs/src/perftest/systems.ts b/packages/ecs/src/perftest/systems.ts new file mode 100644 index 000000000..fbf4081a6 --- /dev/null +++ b/packages/ecs/src/perftest/systems.ts @@ -0,0 +1,134 @@ +import { Component } from "../interface/component/component.js"; +import { always } from "../interface/resources/always.js"; +import { eachTick, ticksAfter } from "./observer.js"; +import { System } from "../interface/system/system.js"; +import { backToBack, createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { idle } from "./suite.js"; + +// Benchmarks of systems added to ticks and removed again while a world ticks back to back. + +const level = Symbol("systems/level"); +declare module "@adobe/ecs" { + export interface Components { + [level]: { type: number }; + } +} +Component.register(level, { type: { type: "number" } }); + +/** Entities the added systems read. */ +const entities = 100; + +const seed = System.create({ name: "systems/seed" }, ({ commands }) => { + for (let i = 0; i < entities; i++) commands.spawn([level])({ [level]: i }); +}); + +let read = 0; + +/** A new system for each tick: a change filter, which counts as watching `level`, and rows to read. */ +const fresh = (): System => System.create({ components: { read: [level], changed: [level] } }, row => { read += row[level]; }); + +/** One module's system, added and removed again and again. */ +const again = System.create({ name: "systems/again", components: { read: [level], changed: [level] } }, row => { read += row[level]; }); + +/** Ticks in each timed stretch. */ +export const churnTicks = 10_000; + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +/** + * The fastest of `stretches` stretches of `churnTicks` ticks, after one to warm up, of a world with `idle` added and, + * with `churn`, a system made for each tick added to the next and removed from the one after, by an observer system + * reading its clock; then, with `after`, ticks with nothing added or removed. What the world holds is the same before + * and after. + */ +const churning = async (churn: (() => System) | null, after: boolean, stretches = 5): Promise => { + const driver = backToBack(); + const world = createWorld({ [always]: true }, { loop: driver }); + let best = Infinity; + try { + await world.run(seed); + world.add(idle); + // Once a system with the filter came and went, ticks are kept for what it filters on, and the loop's state is + // added. + world.add(again); + await ticksAfter(world, 2); + world.remove(again); + await ticksAfter(world, 2); + await world.run(idle); + const before = await world.tables(); + let added: System | null = null; + const stretch = (churned: (() => System) | null): Promise => new Promise(resolve => { + let ticks = 0; + let stretched = 0; + let start = 0; + const unobserve = eachTick(world, () => { + if (churned !== null) { + if (added !== null) world.remove(added); + added = churned(); + world.add(added); + } + if (ticks++ % churnTicks !== 0) return; + const now = performance.now(); + // The first stretch warms up. + if (stretched > 1) best = Math.min(best, now - start); + start = now; + if (stretched++ <= stretches) return; + unobserve(); + if (added !== null) world.remove(added); + added = null; + resolve(); + }); + }); + if (after) { + await stretch(churn); + best = Infinity; + await stretch(null); + } + else await stretch(churn); + // A run, so what removed systems held is released before the world's tables are read. + await world.run(idle); + const held = await world.tables(); + check(held.runners === before.runners && held.arena === before.arena && held.observed === before.observed, `systems: the world holds ${JSON.stringify(held)} after, against ${JSON.stringify(before)} before`); + check(read > 0 || churn === null, "systems: nothing read"); + } + finally { + await world.dispose(); + driver.close(); + } + return best; +}; + +const tickBest = { bytes: 64, basis: "A tick reads at least one cache line of its systems' state." }; + +export const systemBenchmarks: readonly Benchmark[] = [ + { + name: "loop tick add remove", + operation: "Ticks back to back, each adding a new system with a change filter over 100 rows and removing the one the last tick added: systems that come and go with what a UI shows.", + per: "ticks", + n: churnTicks, + typical: [1, 1], + best: tickBest, + run: () => churning(fresh, false), + }, + { + name: "loop tick add remove again", + operation: "Ticks back to back, each adding or removing one module's system with a change filter over 100 rows: a feature switched on and off.", + per: "ticks", + n: churnTicks, + typical: [1, 1], + best: tickBest, + run: () => churning(() => again, false), + }, + { + name: "loop tick idle after add remove", + operation: "Ticks with nothing changed after 60,000 ticks that each added a new system and removed the last: what systems that came and went leave behind in every tick.", + per: "ticks", + n: churnTicks, + typical: [1, 1], + best: tickBest, + run: () => churning(fresh, true), + }, +]; diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts new file mode 100644 index 000000000..ff1373df0 --- /dev/null +++ b/packages/ecs/src/perftest/tails.ts @@ -0,0 +1,306 @@ +/// +import { Component } from "../interface/component/component.js"; +import { Resource } from "../interface/resource/resource.js"; +import { always } from "../interface/resources/always.js"; +import { eachTick, ticksAfter } from "./observer.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { backToBack, createWorld, type CreatedWorld } from "../implementation/index.js"; +import { tailWriters } from "../implementation/world/tails.js"; +import type { Benchmark } from "./benchmark.js"; +import { reportOf, runReported } from "../implementation/world/reports.test-helper.js"; + +// Tails: terminal systems on a joined worker, which claims "tailsBenchmark", running after their tick's sync point +// while the world goes on. The joined thread is `joined.ts`. + +const place = Symbol("tails/place"); +const pose = Symbol("tails/pose"); +const surface = Symbol("tails/surface"); +const poked = Symbol("tails/poked"); +const rowWork = Symbol("tails/rowWork"); +const worldWork = Symbol("tails/worldWork"); +declare module "@adobe/ecs" { + export interface Threads { + tailsBenchmark: true; + } + export interface Components { + [place]: { type: readonly [number, number, number] }; + [pose]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number] }; + } + export interface Resources { + [surface]: { type: { readonly name: string }; thread: "tailsBenchmark" }; + [poked]: { type: number }; + [rowWork]: { type: number }; + [worldWork]: { type: number }; + } +} +Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); +Component.register(pose, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 16, maxItems: 16 } }); +/** An object of the joined thread, as a canvas or the DOM is, which places the systems reading it there. */ +const surfaceStart = { name: "surface" }; +Resource.register(surface, { type: { type: "object", properties: { name: { type: "string" } }, default: surfaceStart }, thread: "tailsBenchmark" }); +Resource.register(poked, { type: { type: "integer", default: 0 } }); +Resource.register(rowWork, { type: { type: "integer", minimum: 0, maximum: 1_000_000, default: 0 } }); +Resource.register(worldWork, { type: { type: "number", default: 0 } }); + +/** What the tails read, so their reads aren't dead. */ +export const drawn = { sum: 0 }; + +/** Rows of the prompt tail, and of the late one's snapshot. */ +export const promptRows = 1_000; +export const lateRows = 10_000; +/** Ticks in each timed stretch of the prompt tail. */ +export const tailTicks = 1_000; + +const spawnPlaces = System.create({ name: "tails/spawnPlaces" }, ({ commands }) => { + for (let k = 0; k < promptRows; k++) commands.spawn([place])({ [place]: [k, 0, 0] }); +}); +const spawnPoses = System.create({ name: "tails/spawnPoses" }, ({ commands }) => { + for (let k = 0; k < lateRows; k++) commands.spawn([place, pose])({ [place]: [k, 0, 0], [pose]: [k, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }); +}); + +const here = System.create({ name: "tails/here" }, () => {}); +/** On the joined thread, terminal: reads every row's place, as a DOM-writing system reads positions. */ +const draw = System.create({ name: "tails/draw", components: { read: [place] }, resources: { read: [surface] } }, (row, { resources }) => { + if (resources[surface] === surfaceStart) drawn.sum += row[place][0]; +}); +/** A tick of a world system and a 1,000-row tail that finishes before the next tick needs it. */ +export const promptTick = System.create({ name: "tails/promptTick", systems: [here, draw] }); + +/** On the joined thread, ahead of the late tail: busy for a while, as a main thread is with layout. */ +const busy = System.create({ name: "tails/busy", resources: { read: [surface] } }, ({ resources }) => { + const until = performance.now() + 4; + while (performance.now() < until && resources[surface] === surfaceStart); +}); +/** On the joined thread, terminal, after `busy`: reads every row's place and pose, and the resource `poke` writes. */ +const copied = System.create({ name: "tails/copied", components: { read: [place, pose] }, resources: { read: [surface, poked] }, after: [busy] }, (row, { resources }) => { + if (resources[surface] === surfaceStart) drawn.sum += row[place][0] + row[pose][15] + resources[poked]; +}); +export const lateTick = System.create({ name: "tails/lateTick", systems: [busy, copied] }); +/** On the world's thread: writes what the late tail reads, so it copies the rest of the tail's reads first. */ +const poke = System.create({ name: "tails/poke", resources: { write: [poked] } }, ({ resources }) => { + resources[poked] += 1; +}); +const nothing = System.create({ name: "tails/nothing" }, () => {}); + +/** Steps of work each row of the overlapping tail does, about 2.5 ms over 10,000 rows; the world's work after its writer. */ +export const overlapWork = 40; +export const overlapWorldMilliseconds = 2; +/** Ticks in each timed stretch of the overlapping tail. */ +export const overlapTicks = 100; + +/** On the world's thread: writes every row's place, which the last tick's tail still reads. */ +const overlapWrite = System.create({ name: "tails/overlapWrite", components: { write: [place] } }, row => { + row[place] = [row[place][0], 1, 0]; +}); +/** On the world's thread, after the writer: the rest of the tick's work, as systems reading what it wrote do. */ +const overlapSimulate = System.create({ name: "tails/overlapSimulate", resources: { read: [worldWork] }, after: [overlapWrite] }, ({ resources }) => { + const until = performance.now() + resources[worldWork]; + while (performance.now() < until); +}); +/** On the joined thread, terminal: works on every row's place and pose, as a DOM-writing system on main does. */ +const overlapDraw = System.create({ name: "tails/overlapDraw", components: { read: [place, pose] }, resources: { read: [surface, rowWork] }, after: [overlapWrite] }, (row, { resources }) => { + if (resources[surface] !== surfaceStart) return; + let sum = row[place][0] + row[pose][15]; + for (let k = resources[rowWork]; k > 0; k--) sum = Math.sqrt(sum + k); + drawn.sum += sum; +}); +export const overlapTick = System.create({ name: "tails/overlapTick", systems: [overlapWrite, overlapSimulate, overlapDraw] }); +const setOverlap = System.create({ name: "tails/setOverlap", resources: { write: [rowWork, worldWork] } }, ({ resources }) => { + resources[rowWork] = overlapWork; + resources[worldWork] = overlapWorldMilliseconds; +}); + +/** Every system the joined thread runs, which it imports so the world can place them there. */ +export const tailSystems: readonly System[] = [promptTick, lateTick, overlapTick]; + +const joinedFile = "./joined.js"; + +/** Starts `joined.ts` on a worker, joined to `world` and claiming the benchmark's thread, once it joined. */ +const startClaiming = async (world: CreatedWorld): Promise<{ readonly terminate: () => Promise }> => { + const message = (port: unknown) => ({ kind: "port", port, threads: ["tailsBenchmark"] }); + let terminate: () => Promise; + if (typeof globalThis.Worker === "function") { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = new globalThis.Worker(new URL("./joined.js", import.meta.url), { type: "module" }); + await new Promise(resolve => { + worker.onmessage = resolve; + worker.postMessage(message(port2), [port2]); + }); + terminate = async () => worker.terminate(); + } + else { + const threads = globalThis.process.getBuiltinModule("node:worker_threads"); + const { port1, port2 } = new threads.MessageChannel(); + world.share(port1); + const worker = new threads.Worker(new URL(joinedFile, import.meta.url)); + await new Promise(resolve => { + worker.once("message", resolve); + worker.postMessage(message(port2), [port2]); + }); + terminate = async () => { await worker.terminate(); }; + } + await new Promise(resolve => { + let done = false; + const stop = world.threads(threads => { + if (done || threads.at(-1)?.status !== "joined") return; + done = true; + queueMicrotask(() => stop()); + resolve(); + }); + }); + return { terminate }; +}; + +/** The fastest of `stretches` stretches of `tailTicks` ticks with the prompt tail, driven back to back. */ +const prompt = async (stretches = 5): Promise => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const joined = await startClaiming(world); + let best = Infinity; + try { + await world.run(spawnPlaces); + const report = await runReported(world, promptTick); + if (report.failures.length > 0 || report.warnings.length > 0) throw new Error(`tail prompt: the tail didn't run as one: ${report.failures[0]?.message ?? report.warnings[0]?.message}.`); + await new Promise(resolve => { + let ticks = 0; + let stretch = 0; + let start = 0; + const unobserve = eachTick(world, () => { + if (ticks++ % tailTicks !== 0) return; + const now = performance.now(); + if (stretch > 1) best = Math.min(best, now - start); + start = now; + if (stretch++ <= stretches) return; + unobserve(); + resolve(); + }); + world.add(promptTick); + }); + } + finally { + await joined.terminate(); + await world.dispose(); + driver.close(); + } + return best; +}; + +/** + * The fastest of `rounds` runs writing what a late tail of 10,000 rows reads, each right after a tick posted it behind + * a busy system on its thread, so the run copies the tail's reads first. + */ +const late = async (rounds = 30): Promise => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0 }, { loop: driver }); + const joined = await startClaiming(world); + let best = Infinity; + try { + await world.run(spawnPoses); + world.add(lateTick); + for (let round = 0; round < rounds; round++) { + // Each run makes the next beat tick, which posts the tail behind `busy`. + const ticked = ticksAfter(world, 1); + await world.run(nothing); + await ticked; + const start = performance.now(); + const run = await world.run(poke); + const time = performance.now() - start; + // Read by a run of its own, after the timed one. + const report = await reportOf(world, run); + if (report.failures.length > 0) throw new Error(`tail late snapshot: ${report.failures[0].message}`); + if (round > 1) best = Math.min(best, time); + // The tail and the tick the run made finish. + await new Promise(resolve => setTimeout(resolve, 12)); + } + } + finally { + await joined.terminate(); + await world.dispose(); + driver.close(); + } + return best; +}; + +/** + * The fastest of `stretches` stretches of `overlapTicks` ticks writing what the last tick's tail reads, driven back to + * back: with snapshots, or with the writer waiting for the tail to finish. + */ +const overlap = async (waiting: boolean, stretches = 5): Promise => { + const driver = backToBack(); + const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); + const joined = await startClaiming(world); + let best = Infinity; + tailWriters.wait = waiting; + try { + await world.run(spawnPoses); + await world.run(setOverlap); + const report = await runReported(world, overlapTick); + if (report.failures.length > 0 || report.warnings.length > 0) throw new Error(`tail overlap: the tail didn't run as one: ${report.failures[0]?.message ?? report.warnings[0]?.message}.`); + await new Promise(resolve => { + let ticks = 0; + let stretch = 0; + let start = 0; + const unobserve = eachTick(world, () => { + if (ticks++ % overlapTicks !== 0) return; + const now = performance.now(); + if (stretch > 1) best = Math.min(best, now - start); + start = now; + if (stretch++ <= stretches) return; + unobserve(); + resolve(); + }); + world.add(overlapTick); + }); + } + finally { + tailWriters.wait = false; + await joined.terminate(); + await world.dispose(); + driver.close(); + } + return best; +}; + +const overlapOperation = "Ticks writing every row's place, which the last tick's tail on a joined worker still reads, then working 2 ms, while the tail works about 2.5 ms over 10,000 rows of a place and a pose: main's DOM work and the world's work after its writer that together outlast a frame or a fixed step, though each fits."; + +export const tailBenchmarks: readonly Benchmark[] = [ + { + name: "tail prompt", + operation: "Ticks of a world system and a terminal system on a joined worker over 1,000 rows, which runs after the sync point and finishes before the next tick needs it: a DOM-writing system on main that keeps up.", + per: "ticks", + n: tailTicks, + typical: [1, 1], + best: { bytes: 256 + promptRows * 12, basis: "One task moves its posted, done, payload and completion lines between cores, and the tail reads each row's 12-byte vec3." }, + run: () => prompt(), + }, + { + name: "tail late snapshot 10K", + operation: "A run writing what a late tail on a joined worker reads, 10,000 passing rows of a vec3 and a mat4: the copy of the tail's reads it makes first, as when main is busy when the next tick writes positions.", + per: "rows copied", + n: lateRows, + typical: [100, 10_000], + best: { bytes: 2 * (12 + 64) + 20, basis: "Each row's 12-byte vec3 and 64-byte mat4 are read and written, and its position, archetype, row and entity, 20 bytes, written." }, + run: () => late(), + }, + { + name: "tail overlap", + operation: `${overlapOperation} The writer copies the rows the tail hasn't read, so the tick and the tail overlap.`, + per: "ticks", + n: overlapTicks, + typical: [1, 1], + best: { bytes: 2 * lateRows * (12 + 64), basis: "The world copies each row's 12-byte vec3 and 64-byte mat4 the tail hasn't read, and the tail reads them, beyond the work each side does." }, + run: () => overlap(false), + }, + { + name: "tail overlap waiting", + operation: `${overlapOperation} The writer waits for the tail to finish instead, the alternative \`tail overlap\` is measured against.`, + per: "ticks", + n: overlapTicks, + typical: [1, 1], + best: { bytes: lateRows * (12 + 64), basis: "The tail reads each row's 12-byte vec3 and 64-byte mat4, beyond the work each side does." }, + run: () => overlap(true), + }, +]; diff --git a/packages/ecs/src/perftest/transfers.ts b/packages/ecs/src/perftest/transfers.ts new file mode 100644 index 000000000..465746a8a --- /dev/null +++ b/packages/ecs/src/perftest/transfers.ts @@ -0,0 +1,93 @@ +import { Resource } from "../interface/resource/resource.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; + +// A benchmark of transfers, in a browser: a loader on a joined worker moving decoded assets to the joined worker that +// claims "transfersBenchmark", whose system takes them, through runs. Both workers are `joined.ts`. + +export const sink = Symbol("transfers/sink"); +declare module "@adobe/ecs" { + export interface Threads { + transfersBenchmark: true; + } + export interface Resources { + [sink]: { type: { readonly read: () => number } | null; thread: "transfersBenchmark" }; + } +} +/** An object of the claimed thread, which places the system reading it there. */ +Resource.register(sink, { thread: "transfersBenchmark" }); + +/** Bytes `take` took, on the claimed thread. */ +export const taken = { bytes: 0 }; + +/** + * On the claimed thread: takes a buffer and a bitmap, as an upload would, closing the bitmap. Its input's fields take + * any value, `{ allOf: [] }`, typed `unknown`, which it narrows. + */ +export const take = System.create({ + name: "transfers/take", input: { type: "object", properties: { buffer: { allOf: [] }, bitmap: { allOf: [] } } }, resources: { read: [sink] }, +}, ({ input, resources }) => { + if (resources[sink] !== null) return; + if (input.buffer instanceof ArrayBuffer) taken.bytes += input.buffer.byteLength; + if (typeof ImageBitmap === "function" && input.bitmap instanceof ImageBitmap) { + taken.bytes += input.bitmap.width * input.bitmap.height * 4; + input.bitmap.close(); + } +}); + +/** Every system the joined threads run, which they import so the world can place them there. */ +export const transferSystems: readonly { readonly name: string }[] = [take]; + +/** Transfers timed, in stretches of `transferBatch`, each made untimed first. */ +export const transferCount = 100; +export const transferBatch = 10; +/** Each transfer's buffer bytes, and its bitmap's side in pixels. */ +export const transferBytes = 1 << 20; +export const transferSide = 1024; + +type Joined = { readonly ask: (message: unknown) => Promise; readonly terminate: () => void }; + +/** Starts `joined.ts` on a browser worker, joining `world` through a channel it's given and claiming `threads`. */ +const startJoined = async (world: CreatedWorld, threads: readonly string[]): Promise => { + const { port1, port2 } = new MessageChannel(); + world.share(port1); + const worker = new Worker(new URL("./joined.js", import.meta.url), { type: "module" }); + const ask = (message: unknown, transfer: Transferable[] = []): Promise => new Promise(resolve => { + worker.onmessage = event => resolve(event.data); + worker.postMessage(message, transfer); + }); + await ask({ kind: "port", port: port2, threads }, [port2]); + return { ask, terminate: () => worker.terminate() }; +}; + +/** Milliseconds the loader took for `transferCount` transfers to the claimed thread, each run posted back to back in batches. */ +const transfers = async (): Promise => { + const world = createWorld({ [workers]: 0 }); + const filler = await startJoined(world, ["transfersBenchmark"]); + const loader = await startJoined(world, []); + try { + const reply = await loader.ask({ kind: "transfers" }); + if (typeof reply !== "object" || reply === null || Reflect.get(reply, "kind") !== "transferred") throw new Error("transfer: the loader didn't transfer."); + const milliseconds: unknown = Reflect.get(reply, "milliseconds"); + return typeof milliseconds === "number" ? milliseconds : NaN; + } + finally { + loader.terminate(); + filler.terminate(); + await world.dispose(); + } +}; + +export const transferBenchmarks: readonly Benchmark[] = [ + { + name: "transfer", + operation: "Runs from a loader on a joined worker, each moving a 1 MB ArrayBuffer and a 1024 by 1024 ImageBitmap to the joined worker whose system takes them, which acknowledges each to the world: decoded textures or audio reaching the thread that uses them.", + per: "transfers", + n: transferCount, + typical: [1, 100], + best: { bytes: 256, basis: "A transfer moves its values' handles in one message, the bytes staying where they are, and the run's slot and completion lines between cores." }, + run: () => transfers(), + }, +]; diff --git a/packages/ecs/src/perftest/uploads.ts b/packages/ecs/src/perftest/uploads.ts new file mode 100644 index 000000000..c90bf9bba --- /dev/null +++ b/packages/ecs/src/perftest/uploads.ts @@ -0,0 +1,82 @@ +import { Component } from "../interface/component/component.js"; +import { Entity } from "../interface/entity/entity.js"; +import { gpuDevice } from "../interface/resources/gpu-device.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/world/create.js"; +import type { Benchmark } from "./benchmark.js"; + +// A drag: the CPU writes one row of a column a render system holds on the GPU, which uploads what changed. + +const place = Symbol("uploads/place"); +declare module "@adobe/ecs" { + export interface Components { + [place]: { type: readonly [number, number, number] }; + } +} +Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); + +const shapes = 100_000; +let spawned: Entity[] = []; +const spawn = System.create({ name: "uploads/spawn" }, ({ commands }) => { + spawned = []; + for (let i = 0; i < shapes; i++) spawned.push(commands.spawn([place])({ [place]: [i, 0, 0] })); +}); +const drag = System.create({ + name: "uploads/drag", + components: { write: [place] }, + input: { type: "object", properties: { shape: Entity.schema, x: { type: "number" } } }, + target: "shape", +}, (row, { input }) => { + row[place] = [input.x, 1, 0]; +}); +/** Draws nothing: its run uploads what it reads. */ +const draw = System.render({ name: "uploads/draw", shader: { accessors: { place } }, after: [drag] }, () => {}); +const frame = System.create({ name: "uploads/frame", systems: [drag, draw] }); +const slide = System.create({ name: "uploads/slide", kernel: "cpu", components: { write: [place] } }, row => { + const [x, y, z] = row[place]; + row[place] = [x + 1, y, z]; +}); +const drawAfterSlide = System.render({ name: "uploads/drawAfterSlide", shader: { accessors: { place } }, after: [slide] }, () => {}); +const slideFrame = System.create({ name: "uploads/slideFrame", systems: [slide, drawAfterSlide] }); + +/** Timed per run by `timed`, which waits for the GPU. */ +export const uploadBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ + name: "gpu upload one row 100K", + operation: "A target system writing one row of 100K 12-byte positions a render system draws, as a drag does, then the render system's run, which uploads what the CPU changed.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 12, basis: "The upload writes the one 12-byte position changed.", on: "gpu" }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + const shape = spawned[shapes / 2]; + let x = 0; + await world.run(frame, { shape, x }); + return await timed(() => world.run(frame, { shape, x: ++x })); + } + finally { + await world.dispose(); + } + }, +}, { + name: "gpu upload all rows 100K", + operation: "A CPU kernel on the main thread writing every row of 100K 12-byte positions a render system draws, then the render system's run, which uploads them.", + per: "rows", + n: shapes, + typical: [1_000, 100_000], + best: { bytes: 36, basis: "The kernel reads and writes each 12-byte position, and the upload writes it.", on: "gpu" }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + await world.run(slideFrame); + return await timed(() => world.run(slideFrame)); + } + finally { + await world.dispose(); + } + }, +}]; diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts new file mode 100644 index 000000000..fac938328 --- /dev/null +++ b/packages/ecs/src/perftest/values.ts @@ -0,0 +1,111 @@ +import { Archetype } from "../interface/archetype/archetype.js"; +import { Component } from "../interface/component/component.js"; +import { Finalizer } from "../interface/finalizer/finalizer.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark, Best } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// Benchmarks of struct values, which a row reads into an object and writes back field by field: a 2D transform's five +// numbers, as an editor's nodes hold, beside a vec2 size. + +const pose = Symbol("values/pose"); +const shown = Symbol("values/shown"); +const extent = Symbol("values/extent"); +const order = Symbol("values/order"); +type Pose = { readonly tx: number; readonly ty: number; readonly rotation: number; readonly sx: number; readonly sy: number }; +declare module "@adobe/ecs" { + export interface Components { + [pose]: { type: Pose }; + [shown]: { type: Pose }; + [extent]: { type: readonly [number, number] }; + [order]: { type: number }; + } +} +const number = { type: "number" } as const; +const poseSchema = { type: "object", properties: { tx: number, ty: number, rotation: number, sx: number, sy: number }, required: ["tx", "ty", "rotation", "sx", "sy"] } as const; +Component.register(pose, { type: poseSchema }); +Component.register(shown, { type: poseSchema }); +Component.register(extent, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); +Component.register(order, { type: number }); + +const Node = Archetype.create([pose, shown, extent, order]); + +const rows = 100_000; + +const seed = System.create({ name: "values/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { + for (let i = 0; i < input.count; i++) commands.spawn(Node)({ [pose]: { tx: i, ty: 0, rotation: 0, sx: 1, sy: 1 }, [shown]: { tx: 0, ty: 0, rotation: 0, sx: 1, sy: 1 }, [extent]: [1, 1], [order]: i }); +}); + +/** Shows each row's pose: a struct read and written whole per row. */ +const showPose = System.create({ name: "values/showPose", components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); + +/** Commits a pose and an extent per row as commands, values every row already has. */ +const commitPose = System.create({ name: "values/commitPose", kernel: "cpu", components: { read: [order] } }, (row, { commands }) => { + commands.add(row.entity, pose, { tx: row[order], ty: 1, rotation: 0, sx: 1, sy: 1 }); + commands.add(row.entity, extent, [2, 2]); +}); + +const seeded = async (pool: number): Promise => { + const world = createWorld({ [workers]: pool }); + await world.run(seed, { count: rows }); + return world; +}; + +/** Entities the watching finalizer saw replace a pose, its last sync point. */ +let replacedPoses = 0; + +export const valueBenchmarks: readonly Benchmark[] = [ + { + name: "struct copy 100K", + operation: "A main-thread row system reading a five-number struct and writing it to another component: a transform shown, copied or edited per row.", + per: "rows", + n: rows, + typical: [1_000, 100_000], + best: { bytes: 80, basis: "Each row reads 40 bytes and writes 40." }, + run: async () => { + const world = await seeded(0); + const best = await fastest(() => world.run(showPose)); + await world.dispose(); + return best; + }, + }, + { + name: "struct replace kernel watched", + operation: "As `struct replace kernel main`, with a changes finalizer watching both components: an edit history recording each commit's replaced values.", + per: "commands", + n: 2 * rows, + typical: [1_000, 100_000], + best: { bytes: 32, basis: "Each command writes its 40-byte struct or 8-byte vec2 and reads its entity's 8-byte location." }, + run: async () => { + // Program-wide, so made here, in this benchmark's own process. + Finalizer.changes({ name: "values/history", watch: [pose, extent] }, ({ groups }) => { + replacedPoses = 0; + for (const group of groups) if (group.replaced.includes(pose)) replacedPoses += group.entities.length; + }); + const world = await seeded(0); + const best = await fastest(() => world.run(commitPose)); + await world.dispose(); + if (replacedPoses !== rows) throw new Error("struct replace kernel watched: wrong count"); + return best; + }, + }, +]; + +/** `struct replace kernel` on the world's thread alone, or on a pool of `pool` workers. */ +export const structReplace = (label: string, pool: number, on?: Best["on"]): Benchmark => ({ + name: `struct replace kernel ${label}`, + operation: "A kernel adding a five-number struct and a vec2 every row already has: a gesture committing its rows' values as commands, so change sets see them.", + per: "commands", + n: 2 * rows, + typical: [1_000, 100_000], + best: { bytes: 32, basis: "Each command writes its 40-byte struct or 8-byte vec2 and reads its entity's 8-byte location.", ...on === undefined ? {} : { on } }, + run: async () => { + const world = await seeded(pool); + const best = await fastest(() => world.run(commitPose)); + await world.dispose(); + return best; + }, +}); diff --git a/packages/ecs/src/perftest/wake.ts b/packages/ecs/src/perftest/wake.ts new file mode 100644 index 000000000..e872aac6b --- /dev/null +++ b/packages/ecs/src/perftest/wake.ts @@ -0,0 +1,40 @@ +import { createMemory } from "../implementation/world/memory.js"; +import { createPool, type FastTask } from "../implementation/world/pool.js"; +import { idleKernel } from "../implementation/world/protocol.js"; +import { platformWorkers } from "../implementation/world/worker-platform.js"; +import type { Benchmark } from "./benchmark.js"; + +/** Round trips in each timed stretch of `wake`. */ +export const wakes = 10_000; + +const idle: FastTask = { kernel: idleKernel, archetype: -1, start: 0, end: 0, tick: 0, lastRun: 0, scalars: [], partials: -1, partialBytes: 0 }; + +/** The fastest of `stretches` stretches of `wakes` round trips to one pool worker waiting in its window, after one to warm up. */ +const wakeRoundTrips = async (stretches = 5): Promise => { + const platform = platformWorkers(); + const port = platform?.start() ?? null; + if (port === null) throw new Error("This platform has no workers."); + const pool = createPool([port], createMemory(true)); + let best = Infinity; + try { + for (let stretch = 0; stretch <= stretches; stretch++) { + const start = performance.now(); + for (let k = 0; k < wakes; k++) await pool.claimed(0, 1, idle, 1)[0]; + if (stretch > 0) best = Math.min(best, performance.now() - start); + } + } + finally { + await pool.terminate(); + } + return best; +}; + +export const wake: Benchmark = { + name: "wake", + operation: "A task posted to a pool worker waiting in its window, which wakes, marks it done and wakes the world's thread: the fixed cost of every tier on workers.", + per: "round trips", + n: wakes, + typical: [1, 100], + best: { bytes: 128, basis: "A round trip moves at least the two cache lines of the ring's posted and done words between cores." }, + run: () => wakeRoundTrips(), +}; diff --git a/packages/ecs/src/samples/particles/build.test.ts b/packages/ecs/src/samples/particles/build.test.ts new file mode 100644 index 000000000..33138a80c --- /dev/null +++ b/packages/ecs/src/samples/particles/build.test.ts @@ -0,0 +1,23 @@ +/// +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, expect, it } from "vitest"; +import { build, type Rollup } from "vite"; + +const packageRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const outDir = realpathSync(mkdtempSync(join(tmpdir(), "ecs-sample-"))); +afterAll(() => rmSync(outDir, { recursive: true, force: true })); + +it("the GPU sample builds as an application, with the world's worker and each kernel's create", async () => { + const output = await build({ + configFile: join(packageRoot, "vite.sample.config.ts"), root: join(packageRoot, "src/samples/particles"), logLevel: "silent", + build: { outDir, emptyOutDir: true }, + }); + const items = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []); + expect(items.some(item => item.fileName.includes("worker"))).toBe(true); + const kernels = items.filter((item): item is Rollup.OutputChunk => item.type === "chunk" && item.name.startsWith("kernel-")); + expect(kernels.length).toBeGreaterThan(0); + for (const kernel of kernels) expect(kernel.exports).toEqual(["create"]); +}, 30000); diff --git a/packages/ecs/src/samples/particles/gpu/main.ts b/packages/ecs/src/samples/particles/gpu/main.ts new file mode 100644 index 000000000..362d6c3e9 --- /dev/null +++ b/packages/ecs/src/samples/particles/gpu/main.ts @@ -0,0 +1,96 @@ +import { Gpu, System, World, gpuDevice } from '../../../index.js'; +import { createDraw, enabled, move, moveOnCpu, position, spawn, visible } from './particles.js'; + +/** + * The particles sample. `?mode=gpu` moves particles on the GPU, `cpu` on workers (uploading positions for drawing + * each frame), and `static` only draws. `?count=` sets how many to spawn; a quarter are visible and enabled. + */ +type Mode = "gpu" | "cpu" | "static"; +const modes: readonly Mode[] = ["gpu", "cpu", "static"]; + +const parameters = new URLSearchParams(location.search); +const mode = modes.find(candidate => candidate === parameters.get("mode")) ?? "gpu"; +const count = Number(parameters.get("count") ?? 1_000_000); + +/** Run times and frame intervals of the last frames, in milliseconds. */ +const kept = 600; +const runs: number[] = []; +const intervals: number[] = []; +const percentile = (values: readonly number[], fraction: number): number => { + const sorted = [...values].sort((a, b) => a - b); + return sorted.length === 0 ? NaN : sorted[Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))]; +}; +const push = (values: number[], value: number): void => { + values.push(value); + if (values.length > kept) values.shift(); +}; + +/** For automated measurement. */ +const stats = () => ({ + mode, + count, + frames: runs.length, + runMedian: percentile(runs, 0.5), + runP95: percentile(runs, 0.95), + intervalMedian: percentile(intervals, 0.5), +}); +Reflect.set(globalThis, "sampleStats", stats); + +const overlay = (text: string): void => { + const element = document.querySelector("#stats"); + if (element !== null) element.textContent = text; +}; + +const start = async (): Promise => { + const adapter = await navigator.gpu?.requestAdapter(); + if (adapter === null || adapter === undefined) return overlay("This browser has no WebGPU."); + const device = await adapter.requestDevice(); + const canvas = document.querySelector("canvas"); + if (canvas === null) return; + canvas.width = Math.floor(canvas.clientWidth * devicePixelRatio); + canvas.height = Math.floor(canvas.clientHeight * devicePixelRatio); + const context = canvas.getContext("webgpu"); + if (context === null) return overlay("No WebGPU canvas context."); + const format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ device, format, alphaMode: "opaque" }); + + const moving = mode === "gpu" ? [move] : mode === "cpu" ? [moveOnCpu] : []; + const world = World.create({ [gpuDevice]: device }); + const { draw, ready } = createDraw(device, Gpu.layout(device), context, format); + await ready; + const frame = System.create({ name: "gpuParticles/frame", systems: [...moving, draw] }); + await world.run(spawn, { count, width: canvas.width, height: canvas.height }); + /** For automated checks: a few drawn particles' positions, which an observer system reads back from the GPU. */ + const probed: (readonly number[])[] = []; + const probe = System.create({ name: "gpuParticles/probe", observer: true, components: { read: [position], has: [visible, enabled] } }, row => { + if (probed.length < 3) probed.push(row[position]); + }); + Reflect.set(globalThis, "sampleProbe", async () => { + probed.length = 0; + await world.run(probe); + return [...probed]; + }); + + // At most two frames of GPU work in flight, so the CPU can't run ahead of the GPU. + const inFlight: Promise[] = []; + let last = performance.now(); + let shown = 0; + const tick = async (now: number): Promise => { + push(intervals, now - last); + last = now; + if (inFlight.length >= 2) await inFlight.shift(); + const begin = performance.now(); + await world.run(frame); + push(runs, performance.now() - begin); + inFlight.push(device.queue.onSubmittedWorkDone()); + if (now - shown > 500) { + shown = now; + const { runMedian, runP95, intervalMedian } = stats(); + overlay(`${mode}: ${(count / 4).toLocaleString()} of ${count.toLocaleString()} particles drawn · run ${runMedian.toFixed(3)} ms (p95 ${runP95.toFixed(3)}) · ${(1000 / intervalMedian).toFixed(0)} fps`); + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +}; + +void start(); diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts new file mode 100644 index 000000000..2345385fe --- /dev/null +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -0,0 +1,138 @@ +import { Archetype, Component, Gpu, System, Vec3 } from '../../../index.js'; + +// The perf particles (src/perftest/particles.ts) on screen: 1M particles over four archetypes by visible and +// enabled, with screen-space positions and velocities in pixels per frame. `move` is the perf move kernel, on the GPU. + +export const position = Symbol("gpuParticles/position"); +export const velocity = Symbol("gpuParticles/velocity"); +export const color = Symbol("gpuParticles/color"); +export const mass = Symbol("gpuParticles/mass"); +export const visible = Symbol("gpuParticles/visible"); +export const enabled = Symbol("gpuParticles/enabled"); + +declare module '@adobe/ecs' { + interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + [color]: { type: number }; + [mass]: { type: number }; + [visible]: { type: true }; + [enabled]: { type: true }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(velocity, { type: Vec3.schema }); +Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); +Component.register(mass, { type: { type: "number", precision: 1 } }); +Component.register(visible, { type: { const: true } }); +Component.register(enabled, { type: { const: true } }); + +/** Entity i goes to archetype i % 4. */ +const archetypes = [ + Archetype.create([color, mass, position, velocity, visible, enabled]), + Archetype.create([color, mass, position, velocity, visible]), + Archetype.create([color, mass, position, velocity, enabled]), + Archetype.create([color, mass, position, velocity]), +] as const; + +/** A deterministic sequence in [0, 1), so every mode starts from the same particles. */ +const random = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +}; + +/** The particle count, and the field's width and height in pixels. Entity i goes to archetype i % 4. */ +export const spawn = System.create({ + name: "gpuParticles/spawn", + input: { type: "object", properties: { count: { type: "integer" }, width: { type: "number" }, height: { type: "number" } } }, +}, ({ input: { count, width, height }, commands }) => { + const next = random(7); + for (let i = 0; i < count; i++) { + const p = [next() * width, next() * height, 0] as const; + const angle = next() * Math.PI * 2; + const speed = 0.25 + next() * 1.75; + const v = [Math.cos(angle) * speed, Math.sin(angle) * speed, 0] as const; + // Opaque, in the colors' upper half so they show on black: RGBA bytes, red lowest. + const rgb = 0x808080 | Math.floor(next() * 0x1000000); + commands.spawn(archetypes[i % 4])({ [color]: (0xff000000 | rgb) >>> 0, [mass]: 1, [position]: p, [velocity]: v }); + } +}); + +export const move = System.create({ + name: "gpuParticles/move", + kernel: "gpu", + components: { read: [velocity], write: [position], has: [visible, enabled] }, +}, row => { + row[position] = Vec3.add(row[position], row[velocity]); +}); + +/** The same move on the CPU, for comparison. */ +export const moveOnCpu = System.create({ + name: "gpuParticles/moveOnCpu", + kernel: "cpu", + components: { read: [velocity], write: [position], has: [visible, enabled] }, +}, row => { + row[position] = Vec3.add(row[position], row[velocity]); +}); + +/** The vertex and fragment stages, after the world's declarations: each particle is a point, wrapped into the field. */ +const shader = ` +struct View { size: vec2f } +@group(1) @binding(0) var view: View; + +struct Point { @builtin(position) position: vec4f, @location(0) color: vec4f } + +@vertex fn vertexMain(@builtin(vertex_index) row: u32) -> Point { + let p = particlePosition(row).xy; + let wrapped = p - floor(p / view.size) * view.size; + let clip = wrapped / view.size * 2.0 - 1.0; + return Point(vec4f(clip.x, -clip.y, 0.0, 1.0), unpack4x8unorm(particleColor(row))); +} + +@fragment fn fragmentMain(point: Point) -> @location(0) vec4f { return point.color; } +`; + +const declaration = { + name: "gpuParticles/draw", + components: { has: [visible, enabled] }, + shader: { group: 0, accessors: { particlePosition: position, particleColor: color } }, + after: [move, moveOnCpu], +} as const; + +/** + * The render system for a canvas: it draws the visible and enabled particles as points, wrapping positions so + * particles leaving one side of the field come back on the other. `layout` is the world's GPU layout. + */ +export const createDraw = (device: GPUDevice, layout: GPUBindGroupLayout, context: GPUCanvasContext, format: GPUTextureFormat) => { + const viewLayout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }] }); + const viewBuffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + const viewGroup = device.createBindGroup({ layout: viewLayout, entries: [{ binding: 0, resource: { buffer: viewBuffer } }] }); + let pipeline: GPURenderPipeline | null = null; + const draw = System.render(declaration, ({ encoder, tables }) => { + if (pipeline === null) return; + const texture = context.getCurrentTexture(); + device.queue.writeBuffer(viewBuffer, 0, new Float32Array([texture.width, texture.height])); + const pass = encoder.beginRenderPass({ colorAttachments: [{ view: texture.createView(), loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 1 } }] }); + pass.setPipeline(pipeline); + pass.setBindGroup(1, viewGroup); + for (const table of tables) { + table.bind(pass); + pass.draw(table.count); + } + pass.end(); + }); + const module = device.createShaderModule({ code: `${Gpu.wgsl(draw)}${shader}` }); + const ready = (async () => { + pipeline = await device.createRenderPipelineAsync({ + layout: device.createPipelineLayout({ bindGroupLayouts: [layout, viewLayout] }), + vertex: { module, entryPoint: "vertexMain" }, + fragment: { module, entryPoint: "fragmentMain", targets: [{ format }] }, + primitive: { topology: "point-list" }, + }); + })(); + return { draw, ready }; +}; diff --git a/packages/ecs/src/samples/particles/index.html b/packages/ecs/src/samples/particles/index.html new file mode 100644 index 000000000..b197be29a --- /dev/null +++ b/packages/ecs/src/samples/particles/index.html @@ -0,0 +1,22 @@ + + + + + + GPU Particles + + + + +
+
Starting…
+ +
+ + + diff --git a/packages/ecs/src/samples/particles/particles.test.ts b/packages/ecs/src/samples/particles/particles.test.ts new file mode 100644 index 000000000..bdcfc9e1e --- /dev/null +++ b/packages/ecs/src/samples/particles/particles.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { System, World, workers } from "../../index.js"; +import { count, frame } from "./particles.js"; + +/** The same simulation in plain f64 JS. */ +const reference = (frames: readonly number[]): number[][] => { + const particles = Array.from({ length: 100 }, (_, i) => ({ position: [0, 0, 0], velocity: [1, i, 0] })); + for (const seconds of frames.slice(1)) { + for (const particle of particles) { + particle.velocity[1] -= 9.8 * seconds; + particle.position = particle.position.map((value, axis) => value + particle.velocity[axis] * seconds); + } + } + return particles.map(particle => particle.position); +}; + +describe("particles", () => { + it("matches a plain JS simulation within storage precision", async () => { + // Kernels run here: under Vitest's module runner, node workers can't load the sources. + await using world = World.create({ [workers]: 0 }); + const frames = [0.5, 0.25, 1 / 60, 1 / 60]; + let positions: readonly (readonly number[])[] = []; + for (const seconds of frames) positions = await frame(world, seconds); + let spawned = 0; + await world.run(System.create({ observer: true, resources: { read: [count] } }, ({ resources }) => { spawned = resources[count]; })); + expect(spawned).toBe(100); + const expected = reference(frames); + expect(positions).toHaveLength(expected.length); + positions.forEach((position, i) => position.forEach((value, axis) => expect(value).toBeCloseTo(expected[i][axis], 3))); + }); +}); diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts new file mode 100644 index 000000000..f6077519b --- /dev/null +++ b/packages/ecs/src/samples/particles/particles.ts @@ -0,0 +1,85 @@ +import { Component, Resource, System, Vec3, type World } from '../../index.js'; + +export const position = Symbol("particles/position"); +export const velocity = Symbol("particles/velocity"); +export const delta = Symbol("particles/delta"); +export const count = Symbol("particles/count"); + +declare module '@adobe/ecs' { + interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + } + interface Resources { + [delta]: { type: number }; + [count]: { type: number }; + } +} + +Component.register(position, { type: Vec3.schema }); +Component.register(velocity, { type: Vec3.schema }); +Resource.register(delta, { type: { type: "number", precision: 1, default: 0 } }); +Resource.register(count, { type: { type: "integer", minimum: 0, default: 100 } }); + +// Main thread: resource-only, runs when `count` changes, including the first run. +export const spawn = System.create({ + name: "particles/spawn", + resources: { read: [count], changed: [count] }, +}, ({ resources, commands }) => { + for (let i = 0; i < resources[count]; i++) { + commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [1, i, 0] }); + } +}); + +// Kernels: linear components and read-only resources, so they can run in chunks across workers. +export const gravity = System.create({ + name: "particles/gravity", + kernel: "cpu", + components: { write: [velocity] }, + resources: { read: [delta] }, +}, (row, { resources }) => { + const [x, y, z] = row[velocity]; + row[velocity] = [x, y - 9.8 * resources[delta], z]; +}); + +export const integrate = System.create({ + name: "particles/integrate", + kernel: "cpu", + components: { read: [velocity], write: [position] }, + resources: { read: [delta] }, + after: [gravity], +}, (row, { resources }) => { + const [px, py, pz] = row[position]; + const [vx, vy, vz] = row[velocity]; + const t = resources[delta]; + row[position] = [px + vx * t, py + vy * t, pz + vz * t]; +}); + +// Transaction carrying frame time into the ECS. +export const setDelta = System.create({ + name: "particles/setDelta", + input: { type: "object", properties: { seconds: { type: "number" } } }, + resources: { write: [delta] }, +}, ({ resources, input }) => { + resources[delta] = input.seconds; +}); + +export const main = System.create({ name: "particles/main", systems: [spawn, gravity, integrate] }); + +// Reads every position the frame left, in spawn order. +const positions: Vec3[] = []; +const read = System.create({ + name: "particles/read", + observer: true, + components: { read: [position] }, +}, row => { + positions.push(row[position]); +}); + +export const frame = async (world: World, seconds: number): Promise => { + await world.run(setDelta, { seconds }); + await world.run(main); + positions.length = 0; + await world.run(read); + return [...positions]; +}; diff --git a/packages/ecs/src/samples/particles/particles.type-test.ts b/packages/ecs/src/samples/particles/particles.type-test.ts new file mode 100644 index 000000000..6eeb735b8 --- /dev/null +++ b/packages/ecs/src/samples/particles/particles.type-test.ts @@ -0,0 +1,7 @@ +import { Resource } from '../../index.js'; +import { count } from './particles.js'; + +// @ts-expect-error a resource schema requires a default +Resource.register(count, { type: { type: "integer" } }); +// @ts-expect-error the default must match the resource type +Resource.register(count, { type: { type: "integer", default: "none" } }); diff --git a/packages/ecs/src/samples/todo/README.md b/packages/ecs/src/samples/todo/README.md new file mode 100644 index 000000000..04313d52f --- /dev/null +++ b/packages/ecs/src/samples/todo/README.md @@ -0,0 +1,174 @@ +# Todo sample + +A todo app whose world drives every change to the DOM through systems, while its elements know nothing about the ECS. +It's the experiment for `ecs-dom.md`: vanilla custom elements, no framework, no persistence. + +`pnpm sample`, then open `/todo/`. Add, toggle, edit (double-click), delete, move (drag, or Alt+↑ and Alt+↓), mark all +done or none, clear those done, filter, and generate 10 or 1,000 todos. + +## How the DOM is driven + +- **Elements** (`*/elements/`) take properties and dispatch DOM events. A property write that changes nothing is + dropped, and an element never changes a property its owner writes: a checkbox click dispatches `todo-toggle` and + leaves the row showing its `done` until the world writes it. State only the DOM owns stays there: the draft in the + entry, the editing state of a row, focus. +- **Fixed elements**, those in `index.html`, are resources: each feature finds its own within the page's root when + the app starts. Every DOM system that uses one declares it in `resources.is`, so it's skipped in a world without it. +- **Repeated elements** are component values on the entity they show, declared on `main`: a todo's `row`. + - *Created* by an ordinary system, `createRows`, over todos without a row, which adds the row by a command. It runs + on the tick after the run that added the todo, since observer systems can't issue commands. + - *Bound* by an observer system, `bindRows`, with the row and the values it shows in `changed`: it writes a new row + once and then only rows whose todo changed, after each run's and tick's sync point. + - *Placed* by an observer system, `placeRows`, which runs the rows whose row or order changed in list order, through + a sorted index, and puts each after the row before it, which is always placed already. A row already in place + isn't touched, and one in the list moves with `moveBefore`, never leaving it, so it keeps its focus; where the + browser has no `moveBefore`, it gets its focus back. + - *Filtered* by the filter feature's observer system, `filterRows`, which sets `hidden` on rows whose visibility + changes, as their todo or the filter changes, and moves focus off a row it hides to the nearest one shown. + - *Removed* by a finalizer on the row, which runs whatever ends the todo, `world.dispose()` included. + - Rows out of view skip their rendering (`content-visibility: auto`). +- **Fixed elements showing counts** are bound by an observer system that runs only when an index's entries changed: + `bindCompletion` looks up `completion/byDone` with `changed: true`, so it writes the summary and the toggle-all box + after a pass in which a todo was added, removed or toggled, and is skipped before its body otherwise. +- **Events become runs** in each feature's `events.ts`: listeners on the page's root call `world.run(system, input)` + and leave the promise; a rejected run goes unhandled, which the browser logs. An event from a row names its todo + through `entityOf(event)`, the entity the first target on its composed path shows: `createRows` attaches each row + to its todo (`attach`), and a move's `item` and `before` are read with `entityOfElement`. Systems acting on one + todo name it as their `target`, so they run on its row alone, writing it in place, and on nothing once it's gone. +- **Failures** of runs and ticks alike are in their reports, entities the world keeps; the app adds `logFailures`, an + observer system with `changed: [Report.errors]` that logs them; each report's errors are written once, and an + observer pass's failures are in the next run's report. + +## Structure + +``` +index.html the page: the fixed elements and their layout +app/app.ts defines the features' elements, creates the world through World.create and connects the events +app/main.ts starts the app on the page +app/log-failures.ts logFailures +features/index.ts exports every feature +shared/feature.ts Feature: what the app needs of a feature +shared/connect.ts on and all: listening for events, and stopping several listeners at once +shared/attach.ts attach, entityOf and entityOfElement: the entity an element shows, for its events +shared/elements/ define: defining the elements features list +features// + data.ts components, resources, archetypes and indexes: the feature's data + model.ts systems with input, one per thing a user does: they change data and know no DOM + dom.ts the components and resources holding elements, and the composite of the systems that drive them + events.ts connect: DOM events to runs of the model's systems + elements/ custom elements, importing only each other + feature.ts the feature as the app composes it +``` + +The features are `todos`; `filter`, `completion` and `generate` build on it. A feature imports only the layers of a +feature it builds on, never one built on it, and never the app. Within one, `model` imports only `data`; `dom` imports +`data` and its elements; `events` imports `model` and `dom`. Registration happens as modules load, so a feature's +components, indexes and finalizers exist wherever one of its modules is imported, and `feature.ts` lists only what +the app must do with it: its elements, by tag name, which the app defines; how it finds its fixed elements; its DOM +systems as one composite, which the app adds; and how it connects its events. Elements are defined by the app rather +than as their modules load: the package's `sideEffects: false` lets a bundle drop a module imported only for that. +Adding a feature is a folder and a line in `features/index.ts`. + +## Tests + +- `*/model.test.ts`, in node: each feature's model systems against a world, read back through observer systems. +- `*/elements/*.browser.test.ts`: elements alone, with no world: properties set, the render checked, events recorded. +- `shared/attach.browser.test.ts`: the entity an element was attached to, found from an event on it or within its open + shadow root, and none for others. +- `*/dom.browser.test.ts`: a feature's DOM systems driving real elements, after the loop ticks: rows inserted, + removed without a node left, moved with only the moved row touched and its focus kept, filtered with focus moved + off, edited; no rows without a list; none for a todo deleted before its tick; several runs before one tick; all + removed on dispose. +- `app/app.browser.test.ts`: the whole app on the page's markup, used through DOM events as a user would, with no + console errors; edits committed on blur; a row's events after its todo is deleted; a tick's system's and observer + system's failures logged. +- `app/proportional.browser.test.ts`: each operation's DOM mutations, and how often the DOM systems reach rows, are + the same with 100 todos as with 2,000, less what each row it changed costs. +- `imports.test.ts`: the layer rules above, elements importing nothing but each other, and worlds, the tests' and + benchmarks' too, made only through `World.create` from the package root, never through the implementation. + `build.test.ts`: the page builds. `app/log-failures.test.ts`: a run's failures logged once each, an observer pass's at the next pass. +- `app/todo.perf-browser-test.ts` and `app/profile.perf-browser-test.ts`, with `pnpm test:perf src/samples/todo`: + times, mutation counts and a profile, logged. + +## Performance + +Apple M1 Max, Chromium, with other benchmarks running on the machine. A run's time includes the observer systems +after it; each is the median of 21 runs, each made after a rendered frame as an event's would be, unless it's a single +pass. The tick after it is timed from its frame's start to the end of its observer systems. Mutations count the page's +and its shadow roots' records until that tick ends. + +| Operation | 1K run ms | 10K run ms | Tick after, ms | Mutations | +|---|---:|---:|---:|---:| +| Run that changes nothing | 0.03–0.10 | 0.025 | 0.04–0.17 | 0 | +| Toggle one | 0.15–0.41 | 0.14–0.18 | 0.05–0.19 | 2 | +| Edit one | 0.10 | 0.06 | 0.03–0.06 | 1 | +| Move last to first | 0.14 | 0.13–0.15 | 0.05 | 2 | +| Add one, with its row | 0.27 | 0.26–0.31 | 0.05–0.08 | 4 | +| Toggle all | 1.0 | 7.2–8.4 | 0.06 | 1 per todo, and 4 | +| Filter half the rows | 0.28 | 2.1 | 0.05 | 1 per row hidden, and 2 | +| Filter half the rows, laid out | 1.8 | 17 | 0.05 | 1 per row hidden, and 2 | +| Generate N todos | 1.7 | 2.6–3.6 | — | not measured | +| Create and place N rows | 9.8 | 94–114 | — | 3 per row | +| Lay N rows out | 7.3 | 39 | — | — | + +Operations on one todo cost about the same at 1K and 10K; ranges are over runs on a busy machine. Moving a row by the +DOM alone takes 0.075 ms of the move's 0.13. Back to back, runs cost far less than one made after a frame: +`app/profile.perf-browser-test.ts` has a run that changes nothing at 2.5–3.7 µs in the app and 1.0 µs in an empty +world, and toggling one at 26 µs with 1K todos, 62 µs with 10K and 400 µs with 100K (see Findings). + +## Findings + +What the current API does and doesn't offer the DOM path, each with the change it would take. + +- **Observer systems can't issue commands,** so a row is created by an ordinary system on the tick after the run that + added its todo. That tick runs at the next animation frame, before that frame is painted, so a row added in response + to an event shows in the frame that would have shown it anyway, at 0.05–0.08 ms. *No change needed.* +- **Observer systems can't declare `up`.** A view entity of its own, `{ view, viewOf }`, can't be bound by an observer + through `up: { through: viewOf }`; as a ticking system, its writes come a tick late, and a new view's first only on + the tick after it's made. *Proposal:* allow `up` in observer systems, which only read through it. +- **`order` can't go with `up`.** Views can't be placed in their models' order from a system over the views. + *Proposal:* let `order` take an index over the entity a reference names, or allow it with `up`. +- **No index finds an entity by its element.** Object parts of index keys are interned by structure + (`index-keys.ts`, `canonical` is `JSON.stringify`), so every element is the same key `{}` and an index on `row` + can't tell rows apart. The sample attaches the entity to the element where the row is made, under a symbol only + `shared/attach.ts` holds, which costs one property per row and nothing per run. *No change needed.* +- **A binding helper can't be typed** from the public types. A generic `bindProperties(element, { label, done })` + builds `components: { read: [element, ...keys] }` with keys widened to `keyof Components`, and `System.create` + rejects it: `Property '"error: its object components and resources live on different threads"' is missing`, since + any component might be on another thread; its body then reads by `Reflect`. *Proposal:* none unless bindings get + their own constructor; a binding is four to six lines. +- **A run's fixed cost** in Chromium is small, and none of it is a task: a run that changes nothing takes 1.0 µs back + to back in an empty world, at most 0.3 µs more with the app's observer systems added, and 2.5 µs in the app + with 1K todos, where a + task round trip takes 7.6 µs. Node's `run idle` is 0.33 µs. A run made right after a frame, as an event's is, takes + 0.02–0.10 ms, which is the CPU coming back from idle, not the ECS. Toggling one todo of 10K takes 62 µs, of which + the profile gives 64% to `mergeEntries` (`index-hash.ts`), keeping `completion/byDone`'s two buckets of 5K entries in + entity order, and 16% to `forEachPassingRange` (`ranges.ts`), the change filters scanning the 1024 row ticks of the + one chunk that changed, for each observer system with `changed`. The rest is the sync point, the observer pass and + the DOM writes, about 13 µs. *Proposals* in the next two findings. +- **A low-cardinality hash index costs O(bucket) per change:** toggling one todo moves it between `byDone`'s buckets, + and each bucket's entries shift to stay in entity order: 26 µs a toggle at 1K, 62 at 10K, 400 at 100K. *Proposal:* + unordered buckets with swap-remove and a position per entity, sorted only when a list is read in entity order; or + a count-only index for what reads only `length`. +- **A change filter scans a whole chunk for one changed row,** 1024 ticks for each system with `changed`. *Proposal:* + a second level of ticks per 32 or 64 rows, or a list of the rows changed per chunk. +- **Nothing awaits the next tick.** Tests watch `clock` changes through an observer, after a first run that passes it + regardless, and a world ticks only while a system that isn't an observer is added. *Proposal:* none for consumers; a + test helper in `src/observe` would do. + +## Not kept + +- **Layers at the top**, one `data.ts`, `model.ts`, `dom.ts` and `events.ts` holding every feature's part: 27 files + instead of 47 and half the imports, but a feature's parts are spread over four files, its tests share files with + the others', nothing shows which feature builds on which, and removing one means editing every layer. +- **The app composing each layer of each feature**, importing every feature's DOM systems, events, element + definitions and resource keys, and taking the fixed elements in a `Page`: the app knew every feature's insides, in + 16 imports where it now has 4. +- **Views as entities of their own**, `{ view, viewOf }`, bound through `up: { through: viewOf }`: the binding became + a ticking system (see Findings) whose creating system had to write a new view's first values itself, and placing + views in list order had to run over their todos. It was slower too: 107 ms instead of 98 to create 10K rows, 0.095 + ms instead of 0.080 to toggle one. Nothing here shows a todo twice. +- **Filtering by an attribute of the list**, which style sheets select rows by: one write instead of a pass over the + rows, 0.025 ms instead of 2.1 at 10K, but dearer once laid out, 25 ms against 17, since every row is restyled. It + also put the filter's meaning in the list's element. +- **A helper building a binding**: see Findings. diff --git a/packages/ecs/src/samples/todo/app/app.browser.test.ts b/packages/ecs/src/samples/todo/app/app.browser.test.ts new file mode 100644 index 000000000..1c14414b9 --- /dev/null +++ b/packages/ecs/src/samples/todo/app/app.browser.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { System } from "../../../index.js"; +import { todo } from "../features/todos/data.js"; +import { row } from "../features/todos/dom.js"; +import { part } from "../shared/elements/elements.test-helper.js"; +import { nextTick, watchTicks } from "../shared/ticks.test-helper.js"; +import { createApp, type App } from "./app.js"; + +/** The page's markup, as index.html has it. */ +const markup = ` +
+ +
+`; + +const found = (name: K): HTMLElementTagNameMap[K] => { + const element = document.querySelector(name); + if (element === null) throw new Error(`No ${name}.`); + return element; +}; + +let app: App; +let errors: unknown[]; +const logged = console.error; + +beforeEach(async () => { + errors = []; + console.error = (...values: unknown[]) => errors.push(values); + document.body.innerHTML = markup; + app = createApp(document.body); + await watchTicks(app.world); +}); + +afterEach(async () => { + await app.dispose(); + console.error = logged; + document.body.replaceChildren(); + expect(errors).toEqual([]); +}); + +const rows = (): HTMLElementTagNameMap["todo-row"][] => [...found("todo-list").querySelectorAll("todo-row")]; +/** What a user sees in the list: each shown row, `x` when done. */ +const shown = (): string[] => rows().filter(row => row.checkVisibility()).map(row => `${row.done ? "x" : "-"} ${row.label}`); +const left = (): string => part(found("todo-summary"), ".count", HTMLElement).textContent ?? ""; + +/** Types `text` into the entry and presses Enter. */ +const enter = (text: string): void => { + const input = part(found("todo-entry"), "input", HTMLInputElement); + input.value = text; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); +}; + +/** Enters a todo, and waits for the tick that shows it. */ +const type = async (text: string): Promise => { + enter(text); + await nextTick(); +}; + +const rowPart = (index: number, selector: string, kind: abstract new () => E): E => { + const row = rows()[index]; + if (row === undefined) throw new Error(`No row ${index}.`); + return part(row, selector, kind); +}; + +describe("the todo app", () => { + it("adds, toggles, edits and deletes todos as a user does", async () => { + // A blank entry dispatches nothing. + enter(" "); + await type("Buy milk"); + await type("Walk the dog"); + expect(shown()).toEqual(["- Buy milk", "- Walk the dog"]); + expect(left()).toBe("2 items left"); + + rowPart(1, ".toggle", HTMLInputElement).click(); + await nextTick(); + expect(shown()).toEqual(["- Buy milk", "x Walk the dog"]); + expect(left()).toBe("1 item left"); + + rowPart(0, ".label", HTMLElement).dispatchEvent(new MouseEvent("dblclick")); + const edit = rowPart(0, ".edit", HTMLInputElement); + edit.value = "Buy oat milk"; + edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + await nextTick(); + expect(shown()).toEqual(["- Buy oat milk", "x Walk the dog"]); + + rowPart(0, ".delete", HTMLButtonElement).click(); + await nextTick(); + expect(shown()).toEqual(["x Walk the dog"]); + expect(rows()).toHaveLength(1); + }); + + it("filters, toggles all and clears completed", async () => { + for (const text of ["a", "b", "c"]) await type(text); + rowPart(1, ".toggle", HTMLInputElement).click(); + await nextTick(); + const filter = (name: string): void => part(found("todo-filters"), `[data-filter=${name}]`, HTMLButtonElement).click(); + filter("active"); + await nextTick(); + expect(shown()).toEqual(["- a", "- c"]); + filter("completed"); + await nextTick(); + expect(shown()).toEqual(["x b"]); + filter("all"); + await nextTick(); + + part(found("todo-toggle-all"), "input", HTMLInputElement).click(); + await nextTick(); + expect(shown()).toEqual(["x a", "x b", "x c"]); + expect(found("todo-toggle-all").checked).toBe(true); + part(found("todo-toggle-all"), "input", HTMLInputElement).click(); + await nextTick(); + expect(shown()).toEqual(["- a", "- b", "- c"]); + + rowPart(0, ".toggle", HTMLInputElement).click(); + rowPart(2, ".toggle", HTMLInputElement).click(); + await nextTick(); + part(found("todo-summary"), ".clear", HTMLButtonElement).click(); + await nextTick(); + expect(shown()).toEqual(["- b"]); + expect(rows()).toHaveLength(1); + }); + + it("moves todos with the keyboard and by dragging", async () => { + for (const text of ["a", "b", "c"]) await type(text); + rows()[2]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", altKey: true, bubbles: true })); + await nextTick(); + expect(shown()).toEqual(["- a", "- c", "- b"]); + const [a, , b] = rows(); + if (a === undefined || b === undefined) throw new Error("No rows."); + const dataTransfer = new DataTransfer(); + a.dispatchEvent(new DragEvent("dragstart", { bubbles: true, composed: true, dataTransfer })); + const bounds = b.getBoundingClientRect(); + b.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, composed: true, dataTransfer, clientY: bounds.bottom - 1 })); + await nextTick(); + expect(shown()).toEqual(["- c", "- b", "- a"]); + }); + + it("generates todos and clears them all, leaving no rows", async () => { + part(found("todo-generate"), "[data-count='1000']", HTMLButtonElement).click(); + await nextTick(); + expect(rows()).toHaveLength(1000); + expect(left()).toBe("1000 items left"); + part(found("todo-toggle-all"), "input", HTMLInputElement).click(); + await nextTick(); + part(found("todo-summary"), ".clear", HTMLButtonElement).click(); + await nextTick(); + expect(rows()).toHaveLength(0); + expect(found("todo-summary").hasAttribute("empty")).toBe(true); + }); + + it("commits an edit when its field loses focus", async () => { + await type("Buy milk"); + rowPart(0, ".label", HTMLElement).dispatchEvent(new MouseEvent("dblclick")); + const edit = rowPart(0, ".edit", HTMLInputElement); + edit.value = "Buy oat milk"; + edit.blur(); + await nextTick(); + expect(shown()).toEqual(["- Buy oat milk"]); + }); + + it("ignores what a row asks once its todo is deleted", async () => { + await type("a"); + await type("b"); + // Both events come before the delete's run applies, so the edit and toggle find no todo. + rowPart(0, ".delete", HTMLButtonElement).click(); + rowPart(0, ".toggle", HTMLInputElement).click(); + rows()[0]?.dispatchEvent(new CustomEvent("todo-edit", { detail: { label: "late" }, bubbles: true, composed: true })); + await nextTick(); + expect(shown()).toEqual(["- b"]); + }); + + it("removes every row and stops listening when disposed", async () => { + for (const text of ["a", "b"]) await type(text); + await app.dispose(); + expect(rows()).toHaveLength(0); + // Events after disposal start no runs, which would reject. + part(found("todo-entry"), "input", HTMLInputElement).value = "c"; + part(found("todo-entry"), "input", HTMLInputElement).dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(rows()).toHaveLength(0); + }); +}); + +// It runs in the tick that gives the todo its row, alongside createRows. +const failing = System.create({ name: "test/failing", components: { has: [todo] } }, () => { + throw new Error("a tick's system failed"); +}); +const failingObserver = System.create({ name: "test/failingObserver", observer: true, components: { has: [row], changed: [row] } }, () => { + throw new Error("a tick's observer failed"); +}); +const pass = System.create({ name: "test/pass", observer: true }, () => {}); + +describe("the todo app's failures", () => { + /** The messages of the failures logged. */ + const tickFailures = (): string[] => errors.flatMap(values => Array.isArray(values) && values[0] === "A run failed" && Array.isArray(values[1]) + ? values[1].flatMap((failure: unknown) => typeof failure === "object" && failure !== null && "message" in failure ? [String(failure.message)] : []) + : []); + + it("are logged when a tick's system fails, once each", async () => { + app.world.add(failing); + await type("a"); + app.world.remove(failing); + await app.world.run(pass); + expect(tickFailures()).toEqual(["a tick's system failed"]); + errors.length = 0; + }); + + it("are logged when a tick's observer fails, by the next pass", async () => { + app.world.add(failingObserver); + await type("a"); + app.world.remove(failingObserver); + await app.world.run(pass); + expect(tickFailures()).toEqual(["a tick's observer failed"]); + errors.length = 0; + }); +}); diff --git a/packages/ecs/src/samples/todo/app/app.ts b/packages/ecs/src/samples/todo/app/app.ts new file mode 100644 index 000000000..7ee6a95e9 --- /dev/null +++ b/packages/ecs/src/samples/todo/app/app.ts @@ -0,0 +1,30 @@ +import { World, workers } from "../../../index.js"; +import * as exported from "../features/index.js"; +import { all } from "../shared/connect.js"; +import { define } from "../shared/elements/define.js"; +import type { Resources } from "../shared/feature.js"; +import { logFailures } from "./log-failures.js"; + +/** The app's features, every one `features/index.ts` exports. Each knows only those it builds on; none knows the app. */ +const features = Object.values(exported); + +export type App = { readonly world: World; readonly dispose: () => Promise }; + +/** + * Starts the app on the page within `root`, its elements defined: a world whose DOM systems drive the page, holding + * the fixed elements its features find there, and whose runs the page's events start. + */ +export const createApp = (root: HTMLElement): App => { + for (const feature of features) define(feature.elements); + const world = World.create(features.reduce((resources, feature) => ({ ...resources, ...feature.resources(root) }), { [workers]: 0 })); + for (const { system } of features) if (system !== null) world.add(system); + world.add(logFailures); + const disconnect = all(...features.map(feature => feature.connect(world, root))); + return { + world, + dispose: async () => { + disconnect(); + await world.dispose(); + }, + }; +}; diff --git a/packages/ecs/src/samples/todo/app/log-failures.test.ts b/packages/ecs/src/samples/todo/app/log-failures.test.ts new file mode 100644 index 000000000..8fd8e608a --- /dev/null +++ b/packages/ecs/src/samples/todo/app/log-failures.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { System, World, workers } from "../../../index.js"; +import { logFailures } from "./log-failures.js"; + +const fails = System.create({ name: "test/fails" }, () => { + throw new Error("broken"); +}); +const failsObserving = System.create({ name: "test/failsObserving", observer: true }, () => { + throw new Error("observer broken"); +}); +const nothing = System.create({ name: "test/nothing" }, () => {}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("logs a run's failures once each, and an observer pass's at the next pass", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await using world = World.create({ [workers]: 0 }); + world.add(logFailures); + await world.run(fails); + expect(error).toHaveBeenCalledWith("A run failed", [expect.objectContaining({ kind: "system", message: "broken" })]); + world.add(failsObserving); + await world.run(nothing); + world.remove(failsObserving); + await world.run(nothing); + expect(error.mock.calls.map(([, failures]) => Array.isArray(failures) ? failures.map(failure => failure.message) : [])).toEqual([["broken"], ["observer broken"]]); +}); diff --git a/packages/ecs/src/samples/todo/app/log-failures.ts b/packages/ecs/src/samples/todo/app/log-failures.ts new file mode 100644 index 000000000..988a8831c --- /dev/null +++ b/packages/ecs/src/samples/todo/app/log-failures.ts @@ -0,0 +1,7 @@ +import { Report, System } from "../../../index.js"; + +/** Logs what reports say failed, a run's an event started and a tick's alike: each report's errors are written once. */ +export const logFailures = System.create({ name: "app/logFailures", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + const { failures } = row[Report.errors]; + if (failures.length > 0) console.error("A run failed", failures); +}); diff --git a/packages/ecs/src/samples/todo/app/main.ts b/packages/ecs/src/samples/todo/app/main.ts new file mode 100644 index 000000000..7d9db1263 --- /dev/null +++ b/packages/ecs/src/samples/todo/app/main.ts @@ -0,0 +1,4 @@ +import { createApp } from "./app.js"; + +/** The todo sample's page: its markup holds the fixed elements, and the app's world drives them. */ +createApp(document.body); diff --git a/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts b/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts new file mode 100644 index 000000000..cd3e9fc3b --- /dev/null +++ b/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts @@ -0,0 +1,100 @@ +/// +import { it } from "vitest"; +import { cdp } from "vitest/browser"; +import { Entity, System, World, workers } from "../../../index.js"; +import { bindCompletion } from "../features/completion/dom.js"; +import { bindFilters, filterRows } from "../features/filter/dom.js"; +import { addGenerated } from "../features/generate/model.js"; +import { bindRows, createRows, placeRows } from "../features/todos/dom.js"; +import { toggleTodo } from "../features/todos/model.js"; +import { entityOfElement } from "../shared/attach.js"; +import { createApp, type App } from "./app.js"; +import { logFailures } from "./log-failures.js"; + +/** + * Where a run's fixed cost goes in Chromium: runs timed one at a time and back to back, in a world gaining the app's + * observer systems one by one and in the app at three sizes, then a sampled profile of toggling at 10K. Logged, never + * asserted: `pnpm test:perf src/samples/todo`. + */ + +const nothing = System.create({ name: "profile/nothing" }, () => {}); +const median = (values: readonly number[]): number => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? NaN; + +/** The median of `repeats` runs awaited one at a time, and the mean of as many issued back to back. */ +const timeRuns = async (run: (repeat: number) => Promise, repeats: number): Promise => { + const times: number[] = []; + for (let repeat = 0; repeat < repeats; repeat++) { + const start = performance.now(); + await run(repeat); + times.push(performance.now() - start); + } + const start = performance.now(); + const pending: Promise[] = []; + for (let repeat = 0; repeat < repeats; repeat++) pending.push(run(repeat)); + await Promise.all(pending); + return `${median(times).toFixed(4)} | ${((performance.now() - start) / repeats).toFixed(4)}`; +}; + +const markup = ""; + +/** The app on the page with `n` todos and their rows, and its todos in list order. */ +const appWith = async (n: number): Promise<{ readonly app: App; readonly listed: readonly Entity[] }> => { + document.body.innerHTML = markup; + const app = createApp(document.body); + await app.world.run(addGenerated, { count: n }); + await app.world.run(createRows); + return { app, listed: [...document.querySelectorAll("todo-row")].map(entityOfElement) }; +}; + +/** The share of a profile's samples each function took, by itself, largest first. */ +const selfTimes = (profile: { readonly nodes: readonly { readonly id: number; readonly callFrame: { readonly functionName: string; readonly url: string; readonly lineNumber: number } }[]; readonly samples?: readonly number[] }): string[] => { + const nodes = new Map(profile.nodes.map(node => [node.id, node])); + const shares = new Map(); + const samples = profile.samples ?? []; + for (const sample of samples) { + const frame = nodes.get(sample)?.callFrame; + if (frame === undefined) continue; + const key = `${frame.functionName === "" ? "(anonymous)" : frame.functionName} ${frame.url.split("/").pop()?.split("?")[0] ?? ""}:${frame.lineNumber + 1}`; + shares.set(key, (shares.get(key) ?? 0) + 1); + } + return [...shares].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([key, count]) => `${(100 * count / samples.length).toFixed(1)}% ${key}`); +}; + +it("profiles a run's fixed cost", async () => { + const lines = ["| Setup | one at a time, ms | back to back, ms per run |", "|---|---:|---:|"]; + lines.push(`| await of a resolved promise | ${await timeRuns(() => Promise.resolve(), 2001)} |`); + lines.push(`| a task: MessageChannel round trip | ${await timeRuns(() => new Promise(resolve => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + channel.port1.close(); + resolve(null); + }; + channel.port2.postMessage(null); + }), 2001)} |`); + { + await using world = World.create({ [workers]: 0 }); + lines.push(`| empty world, a run that changes nothing | ${await timeRuns(() => world.run(nothing), 2001)} |`); + for (const system of [bindRows, placeRows, filterRows, bindFilters, bindCompletion, logFailures, createRows]) { + world.add(system); + lines.push(`| + ${system.name} | ${await timeRuns(() => world.run(nothing), 2001)} |`); + } + } + for (const n of [1000, 10000, 100000]) { + const { app, listed } = await appWith(n); + lines.push(`| app with ${n} todos, a run that changes nothing | ${await timeRuns(() => app.world.run(nothing), 1001)} |`); + lines.push(`| app with ${n} todos, toggle one | ${await timeRuns(repeat => app.world.run(toggleTodo, { entity: listed[(repeat * 7) % n] ?? Entity.none }), 1001)} |`); + await app.dispose(); + } + console.log(lines.join("\n")); + + const { app, listed } = await appWith(10000); + const session = cdp(); + await session.send("Profiler.enable"); + await session.send("Profiler.setSamplingInterval", { interval: 50 }); + await session.send("Profiler.start"); + for (let i = 0; i < 4000; i++) await app.world.run(toggleTodo, { entity: listed[(i * 7) % listed.length] ?? Entity.none }); + const { profile } = await session.send("Profiler.stop"); + console.log(["Toggling one of 10K, self time by function:", ...selfTimes(profile)].join("\n")); + await app.dispose(); + document.body.replaceChildren(); +}); diff --git a/packages/ecs/src/samples/todo/app/proportional.browser.test.ts b/packages/ecs/src/samples/todo/app/proportional.browser.test.ts new file mode 100644 index 000000000..1434d35de --- /dev/null +++ b/packages/ecs/src/samples/todo/app/proportional.browser.test.ts @@ -0,0 +1,157 @@ +import { afterEach, expect, it } from "vitest"; +import { Entity, type World } from "../../../index.js"; +import { toggleAll } from "../features/completion/model.js"; +import { setFilter } from "../features/filter/model.js"; +import { addGenerated } from "../features/generate/model.js"; +import { TodoRow } from "../features/todos/elements/todo-row.js"; +import { addTodo, deleteTodo, editTodo, moveTodo, toggleTodo } from "../features/todos/model.js"; +import { entityOfElement } from "../shared/attach.js"; +import { nextTick, watchTicks } from "../shared/ticks.test-helper.js"; +import { createApp } from "./app.js"; + +/** + * Each operation's work follows what it changed, whatever the list holds: both the DOM's mutations and how often the + * DOM systems reach rows, since elements drop equal writes and systems skip rows already right, which would hide a + * system that visits every row. `app/todo.perf-browser-test.ts` times them. + */ + +const markup = ``; + +afterEach(() => { + document.body.replaceChildren(); +}); + +/** How often the DOM systems reached rows: the row properties they write, `hidden`, and what placing reads and calls. */ +let reaches = 0; + +/** Counts calls of an accessor's `kind` on `target`, until the returned function puts it back. */ +const countAccessor = (target: object, name: string, kind: "get" | "set"): (() => void) => { + const descriptor = Object.getOwnPropertyDescriptor(target, name); + const original = descriptor?.[kind]; + if (descriptor === undefined || original === undefined) throw new Error(`No ${kind} ${name}.`); + Object.defineProperty(target, name, { + ...descriptor, + [kind]: function (this: unknown, ...values: unknown[]): unknown { + reaches += 1; + return Reflect.apply(original, this, values); + }, + }); + return () => Object.defineProperty(target, name, descriptor); +}; + +/** Counts calls of a method on `target`, until the returned function puts it back. */ +const countMethod = (target: object, name: string): (() => void) => { + const descriptor = Object.getOwnPropertyDescriptor(target, name); + const original: unknown = descriptor?.value; + if (descriptor === undefined || typeof original !== "function") throw new Error(`No method ${name}.`); + Object.defineProperty(target, name, { + ...descriptor, + value: function (this: unknown, ...values: unknown[]): unknown { + reaches += 1; + return Reflect.apply(original, this, values); + }, + }); + return () => Object.defineProperty(target, name, descriptor); +}; + +const countReaches = (): (() => void) => { + const restores = [ + countAccessor(TodoRow.prototype, "label", "set"), + countAccessor(TodoRow.prototype, "done", "set"), + countAccessor(HTMLElement.prototype, "hidden", "get"), + countAccessor(HTMLElement.prototype, "hidden", "set"), + countAccessor(Element.prototype, "nextElementSibling", "get"), + countAccessor(Element.prototype, "firstElementChild", "get"), + countMethod(Element.prototype, "moveBefore"), + countMethod(Node.prototype, "insertBefore"), + ]; + return () => { + for (const restore of restores) restore(); + }; +}; + +type Work = { readonly mutations: number; readonly reaches: number }; + +/** The mutations of the page and its open shadow roots, and the rows reached, from `change` until the tick after it. */ +const workOf = async (change: () => Promise): Promise => { + let mutations = 0; + const observer = new MutationObserver(records => { + mutations += records.length; + }); + const options = { attributes: true, childList: true, characterData: true, subtree: true }; + observer.observe(document.body, options); + for (const element of document.body.querySelectorAll("*")) if (element.shadowRoot !== null) observer.observe(element.shadowRoot, options); + reaches = 0; + await change(); + await nextTick(); + mutations += observer.takeRecords().length; + observer.disconnect(); + return { mutations, reaches }; +}; + +/** Work less `rows` mutations and `perRow` reaches for each of them. */ +const beyond = (work: Work, rows: number, perRow: number): Work => ({ mutations: work.mutations - rows, reaches: work.reaches - perRow * rows }); + +/** Each operation's work, on an app holding `n` todos, every other one done. */ +const workWith = async (n: number): Promise> => { + document.body.innerHTML = markup; + const app = createApp(document.body); + await watchTicks(app.world); + const { world } = app; + await world.run(addGenerated, { count: n }); + await nextTick(); + const listed = [...document.querySelectorAll("todo-row")].map(entityOfElement); + const at = (index: number): Entity => listed[(index + n) % n] ?? Entity.none; + for (let i = 0; i < n; i += 2) await world.run(toggleTodo, { entity: at(i) }); + await nextTick(); + const rows = (): HTMLElementTagNameMap["todo-row"][] => [...document.querySelectorAll("todo-row")]; + const work: Record = { + "toggle one": await workOf(() => world.run(toggleTodo, { entity: at(1) })), + "edit one": await workOf(() => world.run(editTodo, { entity: at(3), label: "edited" })), + "move one": await workOf(() => world.run(moveTodo, { entity: at(-1), before: at(0) })), + "add one": await workOf(() => world.run(addTodo, { label: "added" })), + "delete one": await workOf(() => world.run(deleteTodo, { entity: at(5) })), + }; + // A new filter passes every row, reading whether it's hidden, and writes those that change. + const filtered = await workOf(() => world.run(setFilter, { filter: "active" })); + const hidden = rows().filter(row => row.hidden).length; + work["filter, beyond each row read and each hidden"] = beyond({ mutations: filtered.mutations, reaches: filtered.reaches - rows().length }, hidden, 1); + await world.run(setFilter, { filter: "all" }); + await nextTick(); + // Each row marked done gets its label and done written and its visibility read. + const notDone = rows().filter(row => !row.done).length; + work["toggle all, beyond each row marked done"] = beyond(await workOf(() => world.run(toggleAll, { done: true })), notDone, 3); + await app.dispose(); + return work; +}; + +it("does work in proportion to what changed, not to how many todos there are", async () => { + const restore = countReaches(); + try { + const small = await workWith(100); + const large = await workWith(2000); + expect(large).toEqual(small); + expect(small).toEqual({ + // The row's done attribute, and the summary's count; its label, done and visibility, and the summary's + // clear button, shown. + "toggle one": { mutations: 2, reaches: 4 }, + // The row's label; its label and done. + "edit one": { mutations: 1, reaches: 2 }, + // The row out of the list and in again; where it is, and the move. + "move one": { mutations: 2, reaches: 2 }, + // The new row into the list, the tabindex and draggable it sets once connected, and the summary's count, + // its label being written in a shadow root made after the count began; its label, done, visibility, and + // where it goes, and its placing. + "add one": { mutations: 4, reaches: 5 }, + // The row out of the list, and the summary's count. + "delete one": { mutations: 2, reaches: 0 }, + // The filter buttons pressed and released. + "filter, beyond each row read and each hidden": { mutations: 2, reaches: 0 }, + // The summary's count; its clear button. + "toggle all, beyond each row marked done": { mutations: 1, reaches: 1 }, + }); + } + finally { + restore(); + } +}); diff --git a/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts new file mode 100644 index 000000000..5bdfcedcd --- /dev/null +++ b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts @@ -0,0 +1,207 @@ +import { it } from "vitest"; +import { Entity, Report, System, workers, World } from "../../../index.js"; +import { toggleAll } from "../features/completion/model.js"; +import { filter } from "../features/filter/data.js"; +import { setFilter } from "../features/filter/model.js"; +import { addGenerated } from "../features/generate/model.js"; +import { done } from "../features/todos/data.js"; +import { createRows, dom, list } from "../features/todos/dom.js"; +import { addTodo, editTodo, moveTodo, toggleTodo } from "../features/todos/model.js"; +import { entityOfElement } from "../shared/attach.js"; +import { nextTick, watchTicks } from "../shared/ticks.test-helper.js"; +import { createApp } from "./app.js"; + +/** + * How the app's DOM path scales, with 1K and 10K todos: each operation's run is timed, the observer systems that run + * after it included, and then run once more with the DOM's mutations counted, those of the tick after it included. + * Numbers are logged, never asserted: `pnpm test:perf src/samples/todo`. + */ + +const markup = ` +
+ +
`; + +const found = (name: K): HTMLElementTagNameMap[K] => { + const element = document.querySelector(name); + if (element === null) throw new Error(`No ${name}.`); + return element; +}; + +/** The mutations of the page and of every open shadow root on it that `change` makes, until the tick after it. */ +const mutationsOf = async (change: () => Promise): Promise => { + let count = 0; + const observer = new MutationObserver(records => { + count += records.length; + }); + const options = { attributes: true, childList: true, characterData: true, subtree: true }; + observer.observe(document.body, options); + for (const element of document.body.querySelectorAll("*")) if (element.shadowRoot !== null) observer.observe(element.shadowRoot, options); + await change(); + await nextTick(); + count += observer.takeRecords().length; + observer.disconnect(); + return count; +}; + +/** Null where not measured. */ +type Result = { readonly operation: string; readonly n: number; readonly ms: number; readonly mutations: number | null; readonly tick: number | null }; +const results: Result[] = []; + +const median = (values: readonly number[]): number => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? NaN; + +/** When the last animation frame's callback began, which a tick runs in. */ +let frameStart = 0; +const platformFrame = globalThis.requestAnimationFrame.bind(globalThis); +globalThis.requestAnimationFrame = callback => platformFrame(time => { + frameStart = performance.now(); + callback(time); +}); + +/** + * Runs `operation` `repeats` times, each timed and followed by the tick a run brings, timed from its frame's start to + * the end of its observer systems; then once more, counting its mutations. + */ +const measure = async (operation: string, n: number, repeats: number, run: (repeat: number) => Promise, settle: () => void = () => {}): Promise => { + const once = async (repeat: number): Promise => { + const start = performance.now(); + await run(repeat); + settle(); + const ms = performance.now() - start; + // A failing run's report is spawned before its pass, which logs it. + if (failed.length > 0) throw new Error(`${operation} failed: ${JSON.stringify(failed)}`); + return ms; + }; + const times: number[] = []; + const ticks: number[] = []; + for (let repeat = 0; repeat < repeats; repeat++) { + times.push(await once(repeat)); + await nextTick(); + ticks.push(performance.now() - frameStart); + } + const mutations = await mutationsOf(() => once(repeats)); + results.push({ operation, n, ms: median(times), mutations, tick: median(ticks) }); +}; + +const nothing = System.create({ name: "perf/nothing" }, () => {}); + +/** The failures of every report the apps measured spawned, which none should. */ +const failed: Report.Failure[] = []; +const collectFailures = System.create({ name: "perf/collectFailures", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + failed.push(...row[Report.errors].failures); +}); + +/** + * The todos in list order, read once, before anything's timed: indexing a list's `children` far from the last index + * read walks them. + */ +const listedTodos = (): readonly Entity[] => [...found("todo-list").children].map(entityOfElement); + +/** Forces the style and layout a change left, so their cost counts too. */ +const forceLayout = (): void => { + void document.body.offsetHeight; +}; + +/** Marks every other todo done, for filters to hide half the rows. */ +const halfDone = System.create({ name: "perf/halfDone", components: { write: [done] } }, item => { + item[done] = item.entity % 2 === 0; +}); + +const alternateFilter = (world: World) => (repeat: number) => world.run(setFilter, { filter: repeat % 2 === 0 ? "active" : "all" }); + +/** A new app on the page holding `n` generated todos, with no rows yet: the tick that would make them hasn't come. */ +const generated = async (n: number): Promise<{ readonly app: ReturnType; readonly ms: number }> => { + document.body.innerHTML = markup; + const app = createApp(document.body); + app.world.add(collectFailures); + await watchTicks(app.world); + const start = performance.now(); + await app.world.run(addGenerated, { count: n }); + const ms = performance.now() - start; + if (found("todo-list").children.length > 0) throw new Error("A tick made the rows before they were timed."); + return { app, ms }; +}; + +const benchmarkApp = async (n: number): Promise => { + // Rows are created and placed by a run of the system the tick after a run would run, counted on another app. + const counted = await generated(n); + const createMutations = await mutationsOf(() => counted.app.world.run(createRows)); + await counted.app.dispose(); + const { app, ms: generateMs } = await generated(n); + const { world } = app; + const start = performance.now(); + await world.run(createRows); + const created = performance.now(); + forceLayout(); + results.push({ operation: "generate N todos", n, ms: generateMs, mutations: null, tick: null }); + results.push({ operation: "create and place N rows", n, ms: created - start, mutations: createMutations, tick: null }); + results.push({ operation: "lay N rows out", n, ms: performance.now() - created, mutations: null, tick: null }); + await nextTick(); + await measure("add one, with its row", n, 11, async repeat => { + await world.run(addTodo, { label: `added ${repeat}` }); + return world.run(createRows); + }); + const listed = listedTodos(); + const at = (index: number): Entity => listed[(index + listed.length) % listed.length] ?? Entity.none; + await measure("run that changes nothing", n, 21, () => world.run(nothing)); + await measure("toggle one", n, 21, repeat => world.run(toggleTodo, { entity: at(repeat * 7) })); + await measure("edit one", n, 21, repeat => world.run(editTodo, { entity: at(repeat * 11), label: `edited ${repeat}` })); + // Each move rotates the list by one: after r of them, the last is the one r + 1 from the end, the first r from it. + await measure("move last to first", n, 21, repeat => world.run(moveTodo, { entity: at(-1 - repeat), before: at(-repeat) })); + await measure("toggle all", n, 4, repeat => world.run(toggleAll, { done: repeat % 2 === 0 })); + await world.run(halfDone); + await measure("filter half the rows", n, 20, alternateFilter(world)); + await measure("filter half the rows, laid out", n, 20, alternateFilter(world), forceLayout); + await world.run(setFilter, { filter: "all" }); + // The move by the DOM alone, outside the world, which leaves the rows out of list order: measured last. + const element = found("todo-list"); + await measure("move last to first, the DOM alone", n, 21, () => { + const last = element.lastElementChild; + if (last !== null) element.prepend(last); + return world.run(nothing); + }); + await app.dispose(); + document.body.replaceChildren(); +}; + +/** The filter not kept: an attribute of the list, which a style sheet selects rows by. */ +const markList = System.create({ + name: "perf/markList", + observer: true, + resources: { read: [filter], changed: [filter], is: [list] }, +}, ({ resources }) => { + resources[list].dataset.filter = resources[filter]; +}); + +const listStyle = ` +todo-list[data-filter="active"] > todo-row[done], +todo-list[data-filter="completed"] > todo-row:not([done]) { display: none; }`; + +const benchmarkListFilter = async (n: number): Promise => { + const style = document.createElement("style"); + style.textContent = listStyle; + const element = document.createElement("todo-list"); + element.style.display = "block"; + element.style.maxHeight = "600px"; + document.body.append(style, element); + await using world = World.create({ [workers]: 0, [list]: element }); + await watchTicks(world); + for (const system of [dom, markList]) world.add(system); + await world.run(addGenerated, { count: n }); + await world.run(createRows); + await world.run(halfDone); + forceLayout(); + await nextTick(); + await measure("filter half the rows by a list attribute (not kept)", n, 20, alternateFilter(world)); + await measure("filter half the rows by a list attribute, laid out (not kept)", n, 20, alternateFilter(world), forceLayout); + document.body.replaceChildren(); +}; + +it("measures the todo app's DOM path", async () => { + for (const n of [1000, 10000]) { + await benchmarkApp(n); + await benchmarkListFilter(n); + } + const cell = (value: number | null, digits = 0): string => value === null ? "—" : value.toFixed(digits); + console.log(["| Operation | N | Run ms | Tick after it, ms | DOM mutations |", "|---|---:|---:|---:|---:|", ...results.map(result => `| ${result.operation} | ${result.n} | ${result.ms.toFixed(3)} | ${cell(result.tick, 3)} | ${cell(result.mutations)} |`)].join("\n")); +}); diff --git a/packages/ecs/src/samples/todo/build.test.ts b/packages/ecs/src/samples/todo/build.test.ts new file mode 100644 index 000000000..5f6d98708 --- /dev/null +++ b/packages/ecs/src/samples/todo/build.test.ts @@ -0,0 +1,23 @@ +/// +import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, expect, it } from "vitest"; +import { build, type Rollup } from "vite"; + +const packageRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const outDir = realpathSync(mkdtempSync(join(tmpdir(), "ecs-sample-"))); +afterAll(() => rmSync(outDir, { recursive: true, force: true })); + +it("the todo sample builds as an application that defines its elements", async () => { + const output = await build({ + configFile: join(packageRoot, "vite.sample.config.ts"), root: join(packageRoot, "src/samples/todo"), logLevel: "silent", + build: { outDir, emptyOutDir: true }, + }); + const items = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []); + expect(items.some(item => item.fileName === "index.html")).toBe(true); + const code = items.flatMap(item => item.type === "chunk" ? [item.code] : []).join("\n"); + for (const name of ["todo-row", "todo-list", "todo-entry", "todo-filters", "todo-summary", "todo-toggle-all", "todo-generate"]) expect(code).toMatch(new RegExp(`["'\`]${name}["'\`]`)); + expect(readFileSync(join(outDir, "index.html"), "utf8")).toContain(""); +}, 30000); diff --git a/packages/ecs/src/samples/todo/features/completion/data.ts b/packages/ecs/src/samples/todo/features/completion/data.ts new file mode 100644 index 000000000..891819f09 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/data.ts @@ -0,0 +1,5 @@ +import { Index } from "../../../../index.js"; +import { done, todo } from "../todos/data.js"; + +/** Todos by whether they're done: its lists' lengths are the counts, kept as todos change. */ +export const byDone = Index.create({ name: "completion/byDone", key: [done], components: { has: [todo] } }); diff --git a/packages/ecs/src/samples/todo/features/completion/dom.browser.test.ts b/packages/ecs/src/samples/todo/features/completion/dom.browser.test.ts new file mode 100644 index 000000000..512940be4 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/dom.browser.test.ts @@ -0,0 +1,53 @@ +import { afterEach, expect, it } from "vitest"; +import { Entity, workers, World } from "../../../../index.js"; +import { entityOfElement } from "../../shared/attach.js"; +import { settled, watchTicks } from "../../shared/ticks.test-helper.js"; +import * as todos from "../todos/dom.js"; +import { TodoList } from "../todos/elements/todo-list.js"; +import { TodoRow } from "../todos/elements/todo-row.js"; +import { addTodo, deleteTodo, editTodo, toggleTodo } from "../todos/model.js"; +import { dom, summary, toggleAllBox } from "./dom.js"; +import { TodoSummary } from "./elements/todo-summary.js"; +import { TodoToggleAll } from "./elements/todo-toggle-all.js"; +import { clearCompleted, toggleAll } from "./model.js"; +import { define } from "../../shared/elements/define.js"; + +define({ "todo-list": TodoList, "todo-row": TodoRow, "todo-summary": TodoSummary, "todo-toggle-all": TodoToggleAll }); + +afterEach(() => { + document.body.replaceChildren(); +}); + +it("shows the counts and whether all are done, and clears the rows of todos done", async () => { + const list = document.createElement("todo-list"); + const count = document.createElement("todo-summary"); + const box = document.createElement("todo-toggle-all"); + document.body.append(box, list, count); + await using world = World.create({ [workers]: 0, [todos.list]: list, [summary]: count, [toggleAllBox]: box }); + await watchTicks(world); + for (const system of [todos.dom, dom]) world.add(system); + const state = (): string => `${count.remaining} left, ${count.completed} done, ${box.empty ? "empty" : box.checked ? "all done" : "some left"}, ${list.children.length} rows`; + await settled(world.run(clearCompleted)); + expect(state()).toBe("0 left, 0 done, empty, 0 rows"); + + for (const text of ["a", "b"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "c" })); + expect(state()).toBe("3 left, 0 done, some left, 3 rows"); + const entityAt = (index: number) => { + const row = list.children[index]; + const entity = entityOfElement(row ?? null); + if (entity === Entity.none) throw new Error(`No row ${index}.`); + return entity; + }; + await settled(world.run(toggleTodo, { entity: entityAt(0) })); + expect(state()).toBe("2 left, 1 done, some left, 3 rows"); + await settled(world.run(toggleAll, { done: true })); + expect(state()).toBe("0 left, 3 done, all done, 3 rows"); + await settled(world.run(toggleTodo, { entity: entityAt(1) })); + await settled(world.run(editTodo, { entity: entityAt(1), label: "B" })); + expect(state()).toBe("1 left, 2 done, some left, 3 rows"); + await settled(world.run(clearCompleted)); + expect(state()).toBe("1 left, 0 done, some left, 1 rows"); + await settled(world.run(deleteTodo, { entity: entityAt(0) })); + expect(state()).toBe("0 left, 0 done, empty, 0 rows"); +}); diff --git a/packages/ecs/src/samples/todo/features/completion/dom.ts b/packages/ecs/src/samples/todo/features/completion/dom.ts new file mode 100644 index 000000000..63faa2736 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/dom.ts @@ -0,0 +1,39 @@ +import { Resource, System } from "../../../../index.js"; +import { byDone } from "./data.js"; +import { TodoSummary } from "./elements/todo-summary.js"; +import { TodoToggleAll } from "./elements/todo-toggle-all.js"; + +/** The page's summary and toggle-all checkbox; null until the page provides them. */ +export const summary = Symbol("summary"); +export const toggleAllBox = Symbol("toggleAllBox"); + +declare module "@adobe/ecs" { + interface Resources { + [summary]: { type: TodoSummary | null; thread: "main" }; + [toggleAllBox]: { type: TodoToggleAll | null; thread: "main" }; + } +} + +Resource.register(summary, { thread: "main" }); +Resource.register(toggleAllBox, { thread: "main" }); + +/** + * Writes the counts, read off the index's lists, and whether all are done, after a pass in which a todo was added, + * removed or toggled: the index's entries changing is its only change filter. + */ +export const bindCompletion = System.create({ + name: "completion/bindCompletion", + observer: true, + resources: { is: [summary, toggleAllBox] }, + lookup: { byDone: { index: byDone, changed: true } }, +}, ({ resources, lookup }) => { + const remaining = lookup.byDone(false).length; + const completed = lookup.byDone(true).length; + resources[summary].remaining = remaining; + resources[summary].completed = completed; + resources[toggleAllBox].checked = remaining === 0 && completed > 0; + resources[toggleAllBox].empty = remaining + completed === 0; +}); + +/** The completion feature's DOM systems, as one composite: added, it runs on every tick. */ +export const dom = System.create([bindCompletion]); diff --git a/packages/ecs/src/samples/todo/features/completion/elements/elements.browser.test.ts b/packages/ecs/src/samples/todo/features/completion/elements/elements.browser.test.ts new file mode 100644 index 000000000..d0cae34f5 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/elements/elements.browser.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { part, record } from "../../../shared/elements/elements.test-helper.js"; +import { TodoSummary } from "./todo-summary.js"; +import { TodoToggleAll } from "./todo-toggle-all.js"; +import { define } from "../../../shared/elements/define.js"; + +define({ "todo-summary": TodoSummary, "todo-toggle-all": TodoToggleAll }); + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("todo-summary", () => { + it("shows what's left, and the clear button only while some are done; empty while there are none", () => { + const summary = document.createElement("todo-summary"); + document.body.append(summary); + const count = part(summary, ".count", HTMLElement); + const clear = part(summary, ".clear", HTMLButtonElement); + expect(summary.hasAttribute("empty")).toBe(true); + expect(count.textContent).toBe("0 items left"); + expect(clear.hidden).toBe(true); + summary.remaining = 1; + expect(summary.hasAttribute("empty")).toBe(false); + expect(count.textContent).toBe("1 item left"); + summary.completed = 2; + expect(clear.hidden).toBe(false); + summary.remaining = 0; + expect(summary.hasAttribute("empty")).toBe(false); + summary.completed = 0; + expect(summary.hasAttribute("empty")).toBe(true); + expect(clear.hidden).toBe(true); + }); + + it("dispatches todo-clear-completed", () => { + const summary = document.createElement("todo-summary"); + document.body.append(summary); + summary.completed = 1; + const seen = record(document.body, "todo-clear-completed"); + part(summary, ".clear", HTMLButtonElement).click(); + expect(seen).toEqual([{ type: "todo-clear-completed", detail: null, target: summary }]); + }); +}); + +describe("todo-toggle-all", () => { + it("is empty until told otherwise, and checked as told", () => { + const toggleAll = document.createElement("todo-toggle-all"); + document.body.append(toggleAll); + expect(toggleAll.hasAttribute("empty")).toBe(true); + toggleAll.empty = false; + toggleAll.checked = true; + expect(toggleAll.hasAttribute("empty")).toBe(false); + expect(part(toggleAll, "input", HTMLInputElement).checked).toBe(true); + }); + + it("dispatches todo-toggle-all with the state clicked, keeping the state it was given", () => { + const toggleAll = document.createElement("todo-toggle-all"); + document.body.append(toggleAll); + const seen = record(document.body, "todo-toggle-all"); + const input = part(toggleAll, "input", HTMLInputElement); + input.click(); + expect(seen).toEqual([{ type: "todo-toggle-all", detail: { done: true }, target: toggleAll }]); + expect(input.checked).toBe(false); + toggleAll.checked = true; + input.click(); + expect(seen[1]?.detail).toEqual({ done: false }); + expect(input.checked).toBe(true); + }); +}); diff --git a/packages/ecs/src/samples/todo/features/completion/elements/todo-summary.ts b/packages/ecs/src/samples/todo/features/completion/elements/todo-summary.ts new file mode 100644 index 000000000..f492b1d08 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/elements/todo-summary.ts @@ -0,0 +1,82 @@ +/** + * ``: how many todos are left, and the button that clears those done, shown only while some are. + * Properties `remaining` and `completed` drive it; `empty`, reflected as an attribute, is set while both are 0. The + * button dispatches `todo-clear-completed`. + */ + +const template = document.createElement("template"); +template.innerHTML = ` + + +`; + +export type TodoSummaryEvents = { + readonly "todo-clear-completed": CustomEvent; +}; + +type Parts = { readonly count: HTMLElement; readonly clear: HTMLButtonElement }; + +const partsOf = (root: ShadowRoot): Parts => { + const count = root.querySelector(".count"); + const clear = root.querySelector(".clear"); + if (!(count instanceof HTMLElement) || !(clear instanceof HTMLButtonElement)) throw new Error("todo-summary's template is missing a part."); + return { count, clear }; +}; + +const remainingText = (remaining: number): string => `${remaining} ${remaining === 1 ? "item" : "items"} left`; + +export class TodoSummary extends HTMLElement { + readonly #parts: Parts; + #remaining = 0; + #completed = 0; + + constructor() { + super(); + const root = this.attachShadow({ mode: "open" }); + root.append(template.content.cloneNode(true)); + this.#parts = partsOf(root); + this.#parts.count.textContent = remainingText(0); + this.#parts.clear.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("todo-clear-completed", { detail: null, bubbles: true, composed: true })); + }); + } + + connectedCallback(): void { + this.toggleAttribute("empty", this.#remaining + this.#completed === 0); + } + + get remaining(): number { + return this.#remaining; + } + + set remaining(value: number) { + if (value === this.#remaining) return; + this.#remaining = value; + this.#parts.count.textContent = remainingText(value); + this.toggleAttribute("empty", value + this.#completed === 0); + } + + get completed(): number { + return this.#completed; + } + + set completed(value: number) { + if (value === this.#completed) return; + this.#completed = value; + this.#parts.clear.hidden = value === 0; + this.toggleAttribute("empty", this.#remaining + value === 0); + } +} + +declare global { + interface HTMLElementTagNameMap { + "todo-summary": TodoSummary; + } + interface HTMLElementEventMap extends TodoSummaryEvents {} +} diff --git a/packages/ecs/src/samples/todo/features/completion/elements/todo-toggle-all.ts b/packages/ecs/src/samples/todo/features/completion/elements/todo-toggle-all.ts new file mode 100644 index 000000000..0d38bf4d5 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/elements/todo-toggle-all.ts @@ -0,0 +1,69 @@ +/** + * ``: the checkbox that marks every todo done, or none. Property `checked` is whether all are done, + * and `empty`, reflected as an attribute, hides it while there are none. A click dispatches `todo-toggle-all` with + * the state chosen, and the owner writes `checked` back. + */ + +const template = document.createElement("template"); +template.innerHTML = ` + +`; + +export type TodoToggleAllEvents = { + readonly "todo-toggle-all": CustomEvent<{ readonly done: boolean }>; +}; + +export class TodoToggleAll extends HTMLElement { + readonly #input: HTMLInputElement; + #checked = false; + #empty = true; + + constructor() { + super(); + const root = this.attachShadow({ mode: "open" }); + root.append(template.content.cloneNode(true)); + const input = root.querySelector("input"); + if (input === null) throw new Error("todo-toggle-all's template has no input."); + this.#input = input; + input.addEventListener("change", () => { + const chosen = input.checked; + input.checked = this.#checked; + this.dispatchEvent(new CustomEvent("todo-toggle-all", { detail: { done: chosen }, bubbles: true, composed: true })); + }); + } + + connectedCallback(): void { + this.toggleAttribute("empty", this.#empty); + } + + get checked(): boolean { + return this.#checked; + } + + set checked(value: boolean) { + if (value === this.#checked) return; + this.#checked = value; + this.#input.checked = value; + } + + get empty(): boolean { + return this.#empty; + } + + set empty(value: boolean) { + if (value === this.#empty) return; + this.#empty = value; + this.toggleAttribute("empty", value); + } +} + +declare global { + interface HTMLElementTagNameMap { + "todo-toggle-all": TodoToggleAll; + } + interface HTMLElementEventMap extends TodoToggleAllEvents {} +} diff --git a/packages/ecs/src/samples/todo/features/completion/events.ts b/packages/ecs/src/samples/todo/features/completion/events.ts new file mode 100644 index 000000000..3207398fa --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/events.ts @@ -0,0 +1,7 @@ +import { all, on, type Connect } from "../../shared/connect.js"; +import { clearCompleted, toggleAll } from "./model.js"; + +export const connect: Connect = (world, root) => all( + on(root, "todo-toggle-all", event => void world.run(toggleAll, { done: event.detail.done })), + on(root, "todo-clear-completed", () => void world.run(clearCompleted)), +); diff --git a/packages/ecs/src/samples/todo/features/completion/feature.ts b/packages/ecs/src/samples/todo/features/completion/feature.ts new file mode 100644 index 000000000..1bf808f2b --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/feature.ts @@ -0,0 +1,13 @@ +import type { Feature } from "../../shared/feature.js"; +import { dom, summary, toggleAllBox } from "./dom.js"; +import { TodoSummary } from "./elements/todo-summary.js"; +import { TodoToggleAll } from "./elements/todo-toggle-all.js"; +import { connect } from "./events.js"; + +/** Completion: how many todos are left, marking all done or none, and clearing those done. */ +export const completion: Feature = { + elements: { "todo-summary": TodoSummary, "todo-toggle-all": TodoToggleAll }, + resources: root => ({ [summary]: root.querySelector("todo-summary"), [toggleAllBox]: root.querySelector("todo-toggle-all") }), + system: dom, + connect, +}; diff --git a/packages/ecs/src/samples/todo/features/completion/model.test.ts b/packages/ecs/src/samples/todo/features/completion/model.test.ts new file mode 100644 index 000000000..ed57ca1b4 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/model.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { System } from "../../../../index.js"; +import { toggleTodo } from "../todos/model.js"; +import { entityOf, todosOf, withTodos } from "../todos/todos.test-helper.js"; +import { done } from "../todos/data.js"; +import { clearCompleted, toggleAll } from "./model.js"; + +let writes = 0; +/** Counts the todos written since it last ran. */ +const countWrites = System.create({ name: "test/countWrites", observer: true, components: { has: [done], changed: [done] } }, () => { + writes += 1; +}); + +describe("completion", () => { + it("marks every todo done, or none, writing only those that change", async () => { + await using world = await withTodos("a", "b", "c"); + await world.run(toggleTodo, { entity: await entityOf(world, "b") }); + await world.run(countWrites); + writes = 0; + await world.run(toggleAll, { done: true }); + expect((await todosOf(world)).map(item => item.done)).toEqual([true, true, true]); + await world.run(countWrites); + expect(writes).toBe(2); + writes = 0; + await world.run(toggleAll, { done: false }); + expect((await todosOf(world)).map(item => item.done)).toEqual([false, false, false]); + await world.run(countWrites); + expect(writes).toBe(3); + }); + + it("clears the todos that are done", async () => { + await using world = await withTodos("a", "b", "c"); + await world.run(toggleTodo, { entity: await entityOf(world, "a") }); + await world.run(toggleTodo, { entity: await entityOf(world, "c") }); + await world.run(clearCompleted); + expect((await todosOf(world)).map(item => item.label)).toEqual(["b"]); + await world.run(clearCompleted); + expect((await todosOf(world)).map(item => item.label)).toEqual(["b"]); + }); +}); diff --git a/packages/ecs/src/samples/todo/features/completion/model.ts b/packages/ecs/src/samples/todo/features/completion/model.ts new file mode 100644 index 000000000..5bbc5cbf9 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/completion/model.ts @@ -0,0 +1,21 @@ +import { System } from "../../../../index.js"; +import { done, todo } from "../todos/data.js"; +import { byDone } from "./data.js"; + +/** Marks every todo done, or every one not done. Only todos that change are written. */ +export const toggleAll = System.create({ + name: "completion/toggleAll", + input: { type: "object", properties: { done: { type: "boolean" } } }, + components: { write: [done], has: [todo] }, +}, (row, { input }) => { + if (row[done] !== input.done) row[done] = input.done; +}); + +/** Deletes the todos that are done. */ +export const clearCompleted = System.create({ + name: "completion/clearCompleted", + lookup: { byDone: { index: byDone } }, +}, ({ lookup, commands }) => { + const completed = lookup.byDone(true); + for (let i = 0; i < completed.length; i++) commands.despawn(completed.at(i).entity); +}); diff --git a/packages/ecs/src/samples/todo/features/filter/data.ts b/packages/ecs/src/samples/todo/features/filter/data.ts new file mode 100644 index 000000000..0b6931425 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/data.ts @@ -0,0 +1,16 @@ +import { Resource } from "../../../../index.js"; + +export type Filter = "all" | "active" | "completed"; + +export const filterSchema = { anyOf: [{ const: "all" }, { const: "active" }, { const: "completed" }] } as const; + +/** Which todos the list shows: all, those not done, or those done. */ +export const filter = Symbol("filter"); + +declare module "@adobe/ecs" { + interface Resources { + [filter]: { type: Filter }; + } +} + +Resource.register(filter, { type: { ...filterSchema, default: "all" } }); diff --git a/packages/ecs/src/samples/todo/features/filter/dom.browser.test.ts b/packages/ecs/src/samples/todo/features/filter/dom.browser.test.ts new file mode 100644 index 000000000..8641c411f --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/dom.browser.test.ts @@ -0,0 +1,84 @@ +import { afterEach, expect, it } from "vitest"; +import { Entity, workers, World } from "../../../../index.js"; +import { entityOfElement } from "../../shared/attach.js"; +import { settled, watchTicks } from "../../shared/ticks.test-helper.js"; +import * as todos from "../todos/dom.js"; +import { TodoList } from "../todos/elements/todo-list.js"; +import { TodoRow } from "../todos/elements/todo-row.js"; +import { addTodo, toggleTodo } from "../todos/model.js"; +import { dom, filters } from "./dom.js"; +import { TodoFilters } from "./elements/todo-filters.js"; +import { setFilter } from "./model.js"; +import { define } from "../../shared/elements/define.js"; + +define({ "todo-list": TodoList, "todo-row": TodoRow, "todo-filters": TodoFilters }); + +afterEach(() => { + document.body.replaceChildren(); +}); + +it("shows the filter on its buttons, and hides the rows it leaves out, writing only those that change", async () => { + const list = document.createElement("todo-list"); + const buttons = document.createElement("todo-filters"); + document.body.append(buttons, list); + await using world = World.create({ [workers]: 0, [todos.list]: list, [filters]: buttons }); + await watchTicks(world); + for (const system of [todos.dom, dom]) world.add(system); + for (const text of ["a", "b"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "c" })); + const rows = [...list.querySelectorAll("todo-row")]; + const entityAt = (index: number): Entity => { + const element = rows[index]; + const entity = entityOfElement(element ?? null); + if (entity === Entity.none) throw new Error(`No row ${index}.`); + return entity; + }; + await settled(world.run(toggleTodo, { entity: entityAt(1) })); + const shown = (): string => rows.map(element => element.checkVisibility() ? element.label : "_").join(""); + expect(shown()).toBe("abc"); + + const records: MutationRecord[] = []; + const observer = new MutationObserver(delivered => records.push(...delivered)); + observer.observe(list, { attributes: true, attributeFilter: ["hidden"], subtree: true }); + const hiddenWrites = (): number => { + records.push(...observer.takeRecords()); + return records.splice(0).length; + }; + await settled(world.run(setFilter, { filter: "active" })); + expect(buttons.filter).toBe("active"); + expect(shown()).toBe("a_c"); + expect(hiddenWrites()).toBe(1); + await settled(world.run(setFilter, { filter: "completed" })); + expect(shown()).toBe("_b_"); + expect(hiddenWrites()).toBe(3); + // A todo that changes hides or shows as the filter has it. + await settled(world.run(toggleTodo, { entity: entityAt(0) })); + expect(shown()).toBe("ab_"); + expect(hiddenWrites()).toBe(1); + await settled(world.run(addTodo, { label: "d" })); + expect([...list.querySelectorAll("todo-row")].map(element => element.checkVisibility())).toEqual([true, true, false, false]); + await settled(world.run(setFilter, { filter: "all" })); + expect(shown()).toBe("abc"); + observer.disconnect(); +}); + +it("moves focus off a row it hides, to the nearest row it shows", async () => { + const list = document.createElement("todo-list"); + document.body.append(list); + await using world = World.create({ [workers]: 0, [todos.list]: list }); + await watchTicks(world); + for (const system of [todos.dom, dom]) world.add(system); + for (const text of ["a", "b", "c"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "d" })); + const [a, b, c, d] = [...list.querySelectorAll("todo-row")]; + const entityOf = (element: Element | undefined): Entity => entityOfElement(element ?? null); + await world.run(toggleTodo, { entity: entityOf(b) }); + await settled(world.run(toggleTodo, { entity: entityOf(c) })); + // Filtered out: b's nearest row shown is d, after c, which is hidden too. + b?.focus(); + await settled(world.run(setFilter, { filter: "active" })); + expect(document.activeElement).toBe(d); + // Toggled out from under the filter: d has no row shown after it, so a gets the focus. + await settled(world.run(toggleTodo, { entity: entityOf(d) })); + expect(document.activeElement).toBe(a); +}); diff --git a/packages/ecs/src/samples/todo/features/filter/dom.ts b/packages/ecs/src/samples/todo/features/filter/dom.ts new file mode 100644 index 000000000..bbc9def1b --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/dom.ts @@ -0,0 +1,63 @@ +import { Resource, System } from "../../../../index.js"; +import { done } from "../todos/data.js"; +import { row } from "../todos/dom.js"; +import { TodoRow } from "../todos/elements/todo-row.js"; +import { filter, type Filter } from "./data.js"; +import { TodoFilters } from "./elements/todo-filters.js"; + +/** The page's filter buttons; null until the page provides them. */ +export const filters = Symbol("filters"); + +declare module "@adobe/ecs" { + interface Resources { + [filters]: { type: TodoFilters | null; thread: "main" }; + } +} + +Resource.register(filters, { thread: "main" }); + +/** Whether the filter leaves a todo out. */ +const leavesOut = (chosen: Filter, isDone: boolean): boolean => chosen === "completed" ? !isDone : chosen === "active" && isDone; + +const siblingOf = (node: Element, forward: boolean): Element | null => forward ? node.nextElementSibling : node.previousElementSibling; + +/** + * Focuses the row nearest `hiding` that the filter shows, after it or else before it, judged by the rows' `done` + * rather than their `hidden`, which this pass may not have reached yet. + */ +const focusNearest = (hiding: Element, chosen: Filter): void => { + for (const forward of [true, false]) { + for (let node = siblingOf(hiding, forward); node !== null; node = siblingOf(node, forward)) { + if (node instanceof TodoRow && !leavesOut(chosen, node.done)) return node.focus({ preventScroll: true }); + } + } +}; + +/** + * Hides each row the filter leaves out, as its todo or the filter changes, moving focus off it. A new filter passes + * every row, but writes only those whose visibility changes; restyling them costs far more than the pass (see the + * README). + */ +export const filterRows = System.create({ + name: "filter/filterRows", + observer: true, + components: { read: [done, row], changed: [done, row] }, + resources: { read: [filter], changed: [filter] }, +}, (item, { resources }) => { + const element = item[row]; + const hidden = leavesOut(resources[filter], item[done]); + if (element.hidden === hidden) return; + if (hidden && element.matches(":focus-within")) focusNearest(element, resources[filter]); + element.hidden = hidden; +}); + +export const bindFilters = System.create({ + name: "filter/bindFilters", + observer: true, + resources: { read: [filter], changed: [filter], is: [filters] }, +}, ({ resources }) => { + resources[filters].filter = resources[filter]; +}); + +/** The filter feature's DOM systems, as one composite: added, it runs on every tick. */ +export const dom = System.create([filterRows, bindFilters]); diff --git a/packages/ecs/src/samples/todo/features/filter/elements/elements.browser.test.ts b/packages/ecs/src/samples/todo/features/filter/elements/elements.browser.test.ts new file mode 100644 index 000000000..f18137bc7 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/elements/elements.browser.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mutationsOf, part, record } from "../../../shared/elements/elements.test-helper.js"; +import { TodoFilters } from "./todo-filters.js"; +import { define } from "../../../shared/elements/define.js"; + +define({ "todo-filters": TodoFilters }); + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("todo-filters", () => { + const pressed = (filters: HTMLElement): string[] => [...filters.shadowRoot?.querySelectorAll("button[aria-pressed=true]") ?? []].map(button => button.textContent ?? ""); + + it("marks the button of its filter", () => { + const filters = document.createElement("todo-filters"); + document.body.append(filters); + expect(pressed(filters)).toEqual(["All"]); + filters.filter = "completed"; + expect(pressed(filters)).toEqual(["Completed"]); + }); + + it("writes only the buttons whose state changes", () => { + const filters = document.createElement("todo-filters"); + document.body.append(filters); + expect(mutationsOf(filters, () => { + filters.filter = "active"; + }).map(record => record.target instanceof HTMLElement ? record.target.dataset.filter : null)).toEqual(["all", "active"]); + }); + + it("dispatches todo-filter with a clicked button's filter, keeping the filter it was given", () => { + const filters = document.createElement("todo-filters"); + document.body.append(filters); + const seen = record(document.body, "todo-filter"); + part(filters, "[data-filter=active]", HTMLButtonElement).click(); + part(filters, "[data-filter=all]", HTMLButtonElement).click(); + expect(seen).toEqual([ + { type: "todo-filter", detail: { filter: "active" }, target: filters }, + { type: "todo-filter", detail: { filter: "all" }, target: filters }, + ]); + expect(pressed(filters)).toEqual(["All"]); + }); +}); diff --git a/packages/ecs/src/samples/todo/features/filter/elements/todo-filters.ts b/packages/ecs/src/samples/todo/features/filter/elements/todo-filters.ts new file mode 100644 index 000000000..7b2a4c477 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/elements/todo-filters.ts @@ -0,0 +1,70 @@ +/** + * ``: the choice of which todos to show. Its `filter` property marks the chosen button; a click + * dispatches `todo-filter` with the button's filter, and the owner writes `filter` back. + */ + +export type Filter = "all" | "active" | "completed"; + +const filters: readonly Filter[] = ["all", "active", "completed"]; + +const template = document.createElement("template"); +template.innerHTML = ` + + + +`; + +export type TodoFiltersEvents = { + readonly "todo-filter": CustomEvent<{ readonly filter: Filter }>; +}; + +const isFilter = (value: string | undefined): value is Filter => filters.some(item => item === value); + +const listen = (host: HTMLElement, root: ShadowRoot): void => { + root.addEventListener("click", event => { + const chosen = event.target instanceof HTMLButtonElement ? event.target.dataset.filter : undefined; + if (isFilter(chosen)) host.dispatchEvent(new CustomEvent("todo-filter", { detail: { filter: chosen }, bubbles: true, composed: true })); + }); +}; + +const show = (root: ShadowRoot, chosen: Filter): void => { + for (const button of root.querySelectorAll("button")) { + const pressed = String(button.dataset.filter === chosen); + if (button.getAttribute("aria-pressed") !== pressed) button.setAttribute("aria-pressed", pressed); + } +}; + +export class TodoFilters extends HTMLElement { + readonly #root: ShadowRoot; + #filter: Filter = "all"; + + constructor() { + super(); + this.#root = this.attachShadow({ mode: "open" }); + this.#root.append(template.content.cloneNode(true)); + listen(this, this.#root); + } + + get filter(): Filter { + return this.#filter; + } + + set filter(value: Filter) { + if (value === this.#filter) return; + this.#filter = value; + show(this.#root, value); + } +} + +declare global { + interface HTMLElementTagNameMap { + "todo-filters": TodoFilters; + } + interface HTMLElementEventMap extends TodoFiltersEvents {} +} diff --git a/packages/ecs/src/samples/todo/features/filter/events.ts b/packages/ecs/src/samples/todo/features/filter/events.ts new file mode 100644 index 000000000..b0b84cb98 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/events.ts @@ -0,0 +1,4 @@ +import { on, type Connect } from "../../shared/connect.js"; +import { setFilter } from "./model.js"; + +export const connect: Connect = (world, root) => on(root, "todo-filter", event => void world.run(setFilter, { filter: event.detail.filter })); diff --git a/packages/ecs/src/samples/todo/features/filter/feature.ts b/packages/ecs/src/samples/todo/features/filter/feature.ts new file mode 100644 index 000000000..84a69b1c7 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/feature.ts @@ -0,0 +1,12 @@ +import type { Feature } from "../../shared/feature.js"; +import { dom, filters } from "./dom.js"; +import { TodoFilters } from "./elements/todo-filters.js"; +import { connect } from "./events.js"; + +/** Filtering: showing all todos, those not done, or those done. */ +export const filter: Feature = { + elements: { "todo-filters": TodoFilters }, + resources: root => ({ [filters]: root.querySelector("todo-filters") }), + system: dom, + connect, +}; diff --git a/packages/ecs/src/samples/todo/features/filter/model.test.ts b/packages/ecs/src/samples/todo/features/filter/model.test.ts new file mode 100644 index 000000000..375cfdb77 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/model.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from "vitest"; +import { System, workers, World } from "../../../../index.js"; +import { filter, type Filter } from "./data.js"; +import { setFilter } from "./model.js"; + +let seen: Filter | null = null; +const readFilter = System.create({ name: "test/readFilter", observer: true, resources: { read: [filter] } }, ({ resources }) => { + seen = resources[filter]; +}); + +it("shows every todo until a filter is set", async () => { + await using world = World.create({ [workers]: 0 }); + await world.run(readFilter); + expect(seen).toBe("all"); + await world.run(setFilter, { filter: "completed" }); + await world.run(readFilter); + expect(seen).toBe("completed"); +}); diff --git a/packages/ecs/src/samples/todo/features/filter/model.ts b/packages/ecs/src/samples/todo/features/filter/model.ts new file mode 100644 index 000000000..10c1d1d42 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/filter/model.ts @@ -0,0 +1,10 @@ +import { System } from "../../../../index.js"; +import { filter, filterSchema } from "./data.js"; + +export const setFilter = System.create({ + name: "filter/set", + input: { type: "object", properties: { filter: filterSchema } }, + resources: { write: [filter] }, +}, ({ input, resources }) => { + resources[filter] = input.filter; +}); diff --git a/packages/ecs/src/samples/todo/features/generate/elements/elements.browser.test.ts b/packages/ecs/src/samples/todo/features/generate/elements/elements.browser.test.ts new file mode 100644 index 000000000..79398cf3c --- /dev/null +++ b/packages/ecs/src/samples/todo/features/generate/elements/elements.browser.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { part, record } from "../../../shared/elements/elements.test-helper.js"; +import { TodoGenerate } from "./todo-generate.js"; +import { define } from "../../../shared/elements/define.js"; + +define({ "todo-generate": TodoGenerate }); + +describe("todo-generate", () => { + it("dispatches todo-generate with each button's count", () => { + const generate = document.createElement("todo-generate"); + document.body.append(generate); + const seen = record(document.body, "todo-generate"); + part(generate, "[data-count='10']", HTMLButtonElement).click(); + part(generate, "[data-count='1000']", HTMLButtonElement).click(); + expect(seen.map(event => event.detail)).toEqual([{ count: 10 }, { count: 1000 }]); + generate.remove(); + }); +}); diff --git a/packages/ecs/src/samples/todo/features/generate/elements/todo-generate.ts b/packages/ecs/src/samples/todo/features/generate/elements/todo-generate.ts new file mode 100644 index 000000000..768176fe4 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/generate/elements/todo-generate.ts @@ -0,0 +1,36 @@ +/** ``: buttons that ask for generated todos, each dispatching `todo-generate` with its count. */ + +const counts = [10, 1000]; + +const template = document.createElement("template"); +template.innerHTML = ` + +${counts.map(count => ``).join("")}`; + +export type TodoGenerateEvents = { + readonly "todo-generate": CustomEvent<{ readonly count: number }>; +}; + +export class TodoGenerate extends HTMLElement { + constructor() { + super(); + const root = this.attachShadow({ mode: "open" }); + root.append(template.content.cloneNode(true)); + root.addEventListener("click", event => { + const count = event.target instanceof HTMLButtonElement ? Number(event.target.dataset.count) : NaN; + if (Number.isInteger(count)) this.dispatchEvent(new CustomEvent("todo-generate", { detail: { count }, bubbles: true, composed: true })); + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "todo-generate": TodoGenerate; + } + interface HTMLElementEventMap extends TodoGenerateEvents {} +} diff --git a/packages/ecs/src/samples/todo/features/generate/events.ts b/packages/ecs/src/samples/todo/features/generate/events.ts new file mode 100644 index 000000000..c6b7bf7c0 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/generate/events.ts @@ -0,0 +1,4 @@ +import { on, type Connect } from "../../shared/connect.js"; +import { addGenerated } from "./model.js"; + +export const connect: Connect = (world, root) => on(root, "todo-generate", event => void world.run(addGenerated, { count: event.detail.count })); diff --git a/packages/ecs/src/samples/todo/features/generate/feature.ts b/packages/ecs/src/samples/todo/features/generate/feature.ts new file mode 100644 index 000000000..dba166169 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/generate/feature.ts @@ -0,0 +1,11 @@ +import type { Feature } from "../../shared/feature.js"; +import { TodoGenerate } from "./elements/todo-generate.js"; +import { connect } from "./events.js"; + +/** Generating: adding many todos at once, to try the app at scale. Its element drives nothing, so it has no DOM systems. */ +export const generate: Feature = { + elements: { "todo-generate": TodoGenerate }, + resources: () => ({}), + system: null, + connect, +}; diff --git a/packages/ecs/src/samples/todo/features/generate/model.test.ts b/packages/ecs/src/samples/todo/features/generate/model.test.ts new file mode 100644 index 000000000..66863493d --- /dev/null +++ b/packages/ecs/src/samples/todo/features/generate/model.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; +import { labelsOf, todosOf, withTodos } from "../todos/todos.test-helper.js"; +import { addGenerated, generatedLabel } from "./model.js"; + +it("adds generated todos after the ones there, each label numbered", async () => { + await using world = await withTodos("first"); + await world.run(addGenerated, { count: 3 }); + expect(await labelsOf(world)).toEqual(["first", generatedLabel(1), generatedLabel(2), generatedLabel(3)]); + expect(new Set(await labelsOf(world)).size).toBe(4); + const orders = (await todosOf(world)).map(item => item.order); + expect([...orders].sort((a, b) => a - b)).toEqual(orders); + expect(generatedLabel(0)).toBe("Buy milk #1"); +}); diff --git a/packages/ecs/src/samples/todo/features/generate/model.ts b/packages/ecs/src/samples/todo/features/generate/model.ts new file mode 100644 index 000000000..a589c8110 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/generate/model.ts @@ -0,0 +1,19 @@ +import { System } from "../../../../index.js"; +import { Todo, done, label, order, orderAfter, ordered } from "../todos/data.js"; + +const tasks = ["Buy milk", "Call the plumber", "Fix the bike", "Read a novel", "Write a letter", "Plan the trip", "Clean the kitchen", "Book a table"]; + +/** The label of the `n`th generated todo. */ +export const generatedLabel = (n: number): string => `${tasks[n % tasks.length]} #${n + 1}`; + +/** Adds `count` todos with generated labels at the end of the list. */ +export const addGenerated = System.create({ + name: "generate/add", + input: { type: "object", properties: { count: { type: "integer", minimum: 0 } } }, + lookup: { ordered: { index: ordered, read: [order] } }, +}, ({ input, lookup, commands }) => { + const all = lookup.ordered(); + const first = orderAfter(all); + const spawn = commands.spawn(Todo); + for (let i = 0; i < input.count; i++) spawn({ [label]: generatedLabel(all.length + i), [done]: false, [order]: first + i }); +}); diff --git a/packages/ecs/src/samples/todo/features/index.ts b/packages/ecs/src/samples/todo/features/index.ts new file mode 100644 index 000000000..9f7b4c6c9 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/index.ts @@ -0,0 +1,4 @@ +export { completion } from "./completion/feature.js"; +export { filter } from "./filter/feature.js"; +export { generate } from "./generate/feature.js"; +export { todos } from "./todos/feature.js"; diff --git a/packages/ecs/src/samples/todo/features/todos/data.ts b/packages/ecs/src/samples/todo/features/todos/data.ts new file mode 100644 index 000000000..a5c574f3c --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/data.ts @@ -0,0 +1,31 @@ +import { Archetype, Component, Index } from "../../../../index.js"; + +/** A todo's text. */ +export const label = Symbol("label"); +export const done = Symbol("done"); +/** Where a todo sits in the list: todos are listed by ascending order. */ +export const order = Symbol("order"); +export const todo = Symbol("todo"); + +declare module "@adobe/ecs" { + interface Components { + [label]: { type: string }; + [done]: { type: boolean }; + [order]: { type: number }; + [todo]: { type: true }; + } +} + +Component.register(label, { type: { type: "string" } }); +Component.register(done, { type: { type: "boolean" } }); +Component.register(order, { type: { type: "number" } }); +Component.register(todo, { type: { const: true } }); + +export const Todo = Archetype.create([todo, label, done, order]); + +/** Every todo, in list order. */ +export const ordered = Index.create({ name: "todos/ordered", key: [order], sorted: true, components: { has: [todo] } }); + +/** The order just after the last of `ordered`'s todos, as a lookup through it gives them: 0 for none. */ +export const orderAfter = (todos: { readonly length: number; readonly at: (index: number) => { readonly [order]: number } }): number => + todos.length === 0 ? 0 : todos.at(todos.length - 1)[order] + 1; diff --git a/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts new file mode 100644 index 000000000..952fd6819 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Entity, System, workers, World } from "../../../../index.js"; +import { entityOfElement } from "../../shared/attach.js"; +import { settled, watchTicks } from "../../shared/ticks.test-helper.js"; +import { dom, list, row } from "./dom.js"; +import { TodoList } from "./elements/todo-list.js"; +import { TodoRow } from "./elements/todo-row.js"; +import { addTodo, deleteTodo, editTodo, moveTodo, toggleTodo } from "./model.js"; +import { todosOf } from "./todos.test-helper.js"; +import { define } from "../../shared/elements/define.js"; + +define({ "todo-list": TodoList, "todo-row": TodoRow }); + +afterEach(() => { + document.body.replaceChildren(); +}); + +/** A world driving a list on the page through the todos feature's DOM systems. */ +const createDriven = async (): Promise<{ readonly world: World; readonly element: TodoList }> => { + const element = document.createElement("todo-list"); + document.body.append(element); + const world = World.create({ [workers]: 0, [list]: element }); + await watchTicks(world); + world.add(dom); + return { world, element }; +}; + +const rowsOf = (element: TodoList): TodoRow[] => [...element.querySelectorAll("todo-row")]; + +let rows = 0; +const countRows = System.create({ name: "test/countRows", observer: true, components: { has: [row] } }, () => { + rows += 1; +}); + +/** How many todos have a row, placed or not. */ +const rowCount = async (world: World): Promise => { + rows = 0; + await world.run(countRows); + return rows; +}; + +/** The element a mutation of `node` belongs to: the host of the shadow root it's in, or itself. */ +const hostOf = (node: Node): Node => { + const root = node.getRootNode(); + return root instanceof ShadowRoot ? root.host : node; +}; + +const shown = (element: TodoList): string[] => rowsOf(element).map(row => `${row.done ? "x" : "-"} ${row.label}`); + +const todoAt = (element: TodoList, index: number): Entity => { + const row = rowsOf(element)[index]; + const entity = entityOfElement(row ?? null); + if (entity === Entity.none) throw new Error(`No row ${index}.`); + return entity; +}; + +/** The child list changes `run` makes to the list once its tick passed. */ +const childChanges = async (element: TodoList, run: () => Promise): Promise<{ readonly added: number; readonly removed: number }> => { + const records: MutationRecord[] = []; + const observer = new MutationObserver(delivered => records.push(...delivered)); + observer.observe(element, { childList: true }); + await settled(run()); + records.push(...observer.takeRecords()); + observer.disconnect(); + return { added: records.reduce((sum, record) => sum + record.addedNodes.length, 0), removed: records.reduce((sum, record) => sum + record.removedNodes.length, 0) }; +}; + +describe("the todos feature's DOM systems", () => { + it("give each todo a row, in list order, once the tick after it passed", async () => { + const { world, element } = await createDriven(); + await using _ = world; + await world.run(addTodo, { label: "milk" }); + await settled(world.run(addTodo, { label: "eggs" })); + expect(shown(element)).toEqual(["- milk", "- eggs"]); + }); + + it("write only the rows whose todo changed", async () => { + const { world, element } = await createDriven(); + await using _ = world; + for (const text of ["a", "b", "c"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "d" })); + const rows = rowsOf(element); + const records: MutationRecord[] = []; + const observer = new MutationObserver(delivered => records.push(...delivered)); + for (const row of rows) { + observer.observe(row, { attributes: true }); + if (row.shadowRoot !== null) observer.observe(row.shadowRoot, { subtree: true, childList: true, characterData: true, attributes: true }); + } + await settled(world.run(toggleTodo, { entity: todoAt(element, 1) })); + await settled(world.run(editTodo, { entity: todoAt(element, 2), label: "C" })); + records.push(...observer.takeRecords()); + const changed = new Set(records.map(record => hostOf(record.target))); + observer.disconnect(); + expect([...changed]).toEqual([rows[1], rows[2]]); + expect(shown(element)).toEqual(["- a", "x b", "- C", "- d"]); + }); + + it("remove a deleted todo's row, leaving no node behind", async () => { + const { world, element } = await createDriven(); + await using _ = world; + for (const text of ["a", "b"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "c" })); + const removed = rowsOf(element)[1]; + await settled(world.run(deleteTodo, { entity: todoAt(element, 1) })); + expect(shown(element)).toEqual(["- a", "- c"]); + expect(removed?.isConnected).toBe(false); + await settled(world.run(editTodo, { entity: todoAt(element, 0), label: " " })); + expect(shown(element)).toEqual(["- c"]); + expect(element.children).toHaveLength(1); + }); + + it("move only the row whose todo moved", async () => { + const { world, element } = await createDriven(); + await using _ = world; + for (const text of ["a", "b", "c"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "d" })); + expect(await childChanges(element, () => world.run(moveTodo, { entity: todoAt(element, 3), before: todoAt(element, 1) }))).toEqual({ added: 1, removed: 1 }); + expect(shown(element)).toEqual(["- a", "- d", "- b", "- c"]); + expect(await childChanges(element, () => world.run(moveTodo, { entity: todoAt(element, 0), before: Entity.none }))).toEqual({ added: 1, removed: 1 }); + expect(shown(element)).toEqual(["- d", "- b", "- c", "- a"]); + expect(await childChanges(element, () => world.run(moveTodo, { entity: todoAt(element, 3), before: todoAt(element, 0) }))).toEqual({ added: 1, removed: 1 }); + expect(shown(element)).toEqual(["- a", "- d", "- b", "- c"]); + }); + + it("keep a moved row focused, so it can be moved again by keyboard", async () => { + const { world, element } = await createDriven(); + await using _ = world; + for (const text of ["a", "b"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "c" })); + const moved = rowsOf(element)[2]; + moved?.focus(); + expect(document.activeElement).toBe(moved); + await settled(world.run(moveTodo, { entity: todoAt(element, 2), before: todoAt(element, 0) })); + expect(shown(element)).toEqual(["- c", "- a", "- b"]); + expect(document.activeElement).toBe(moved); + }); + + it("keep a moved row focused where the browser has no moveBefore", async () => { + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, "moveBefore"); + Reflect.deleteProperty(Element.prototype, "moveBefore"); + try { + const { world, element } = await createDriven(); + await using _ = world; + for (const text of ["a", "b"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "c" })); + const moved = rowsOf(element)[2]; + moved?.focus(); + await settled(world.run(moveTodo, { entity: todoAt(element, 2), before: todoAt(element, 0) })); + expect(shown(element)).toEqual(["- c", "- a", "- b"]); + expect(document.activeElement).toBe(moved); + } + finally { + if (descriptor !== undefined) Object.defineProperty(Element.prototype, "moveBefore", descriptor); + } + }); + + it("make no rows while the page has no list", async () => { + await using world = World.create({ [workers]: 0 }); + await watchTicks(world); + world.add(dom); + await settled(world.run(addTodo, { label: "a" })); + expect(await rowCount(world)).toBe(0); + }); + + it("give no row to a todo deleted before the tick that would make it", async () => { + const { world, element } = await createDriven(); + await using _ = world; + await world.run(addTodo, { label: "a" }); + const [added] = await todosOf(world); + await settled(world.run(deleteTodo, { entity: added?.entity ?? Entity.none })); + expect(element.children).toHaveLength(0); + expect(await rowCount(world)).toBe(0); + }); + + it("show what several runs before one tick changed", async () => { + const { world, element } = await createDriven(); + await using _ = world; + await world.run(addTodo, { label: "a" }); + const [added] = await todosOf(world); + const entity = added?.entity ?? Entity.none; + await world.run(toggleTodo, { entity }); + await settled(world.run(editTodo, { entity, label: "A" })); + expect(shown(element)).toEqual(["x A"]); + }); + + it("place the rows of todos added together in list order", async () => { + const { world, element } = await createDriven(); + await using _ = world; + await world.run(addTodo, { label: "a" }); + await settled(world.run(addTodo, { label: "b" })); + await world.run(moveTodo, { entity: todoAt(element, 1), before: todoAt(element, 0) }); + for (const text of ["c", "d"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "e" })); + expect(shown(element)).toEqual(["- b", "- a", "- c", "- d", "- e"]); + }); + + it("remove every row when the world is disposed", async () => { + const { world, element } = await createDriven(); + for (const text of ["a", "b"]) await world.run(addTodo, { label: text }); + await settled(world.run(addTodo, { label: "c" })); + expect(element.children).toHaveLength(3); + await world.dispose(); + expect(element.children).toHaveLength(0); + }); +}); diff --git a/packages/ecs/src/samples/todo/features/todos/dom.ts b/packages/ecs/src/samples/todo/features/todos/dom.ts new file mode 100644 index 000000000..6b5d1ebb0 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/dom.ts @@ -0,0 +1,90 @@ +import { Component, Finalizer, Index, Resource, System } from "../../../../index.js"; +import { attach } from "../../shared/attach.js"; +import { done, label, order, todo } from "./data.js"; +import { TodoList } from "./elements/todo-list.js"; +import { TodoRow } from "./elements/todo-row.js"; + +/** The row showing a todo. */ +export const row = Symbol("row"); +/** The page's list, which rows are placed in; null until the page provides it. */ +export const list = Symbol("list"); + +declare module "@adobe/ecs" { + interface Components { + [row]: { type: TodoRow; thread: "main" }; + } + interface Resources { + [list]: { type: TodoList | null; thread: "main" }; + } +} + +Component.register(row, { thread: "main" }); +Resource.register(list, { thread: "main" }); + +/** Todos with rows, in list order. */ +const placed = Index.create({ name: "todos/placed", key: [order], sorted: true, components: { has: [todo, row] } }); + +/** + * Gives each new todo a row, attached to it for events from the row, while the page has a list for it. It isn't placed + * until the row is the todo's, so a todo gone first never shows one. + */ +export const createRows = System.create({ + name: "todos/createRows", + components: { has: [todo], not: [row] }, + resources: { is: [list] }, +}, (item, { commands }) => { + commands.add(item.entity, row, attach(document.createElement("todo-row"), item.entity)); +}); + +/** Writes what changed of each todo to its row. */ +export const bindRows = System.create({ + name: "todos/bindRows", + observer: true, + components: { read: [label, done, row], changed: [label, done, row] }, +}, item => { + const element = item[row]; + element.label = item[label]; + element.done = item[done]; +}); + +/** + * Puts `element` before `before` in `parent`. One already there moves with `moveBefore`, without leaving it, where the + * browser has it; otherwise it leaves and comes back, and gets back the focus it had. + */ +const place = (parent: Element, element: HTMLElement, before: Element | null): void => { + const moveBefore: unknown = Reflect.get(parent, "moveBefore"); + if (element.parentNode === parent && typeof moveBefore === "function") { + Reflect.apply(moveBefore, parent, [element, before]); + return; + } + const focused = element.matches(":focus"); + parent.insertBefore(element, before); + if (focused) element.focus({ preventScroll: true }); +}; + +/** + * Places each new or moved row after the row before it in list order. Rows pass in list order, so the one before is + * always placed already. A row already there isn't touched, and one in the list moves without leaving it, which keeps + * its focus. + */ +export const placeRows = System.create({ + name: "todos/placeRows", + observer: true, + components: { read: [row, order], changed: [row, order] }, + order: placed, + resources: { is: [list] }, + lookup: { placed: { index: placed, read: [row] } }, +}, (item, { resources, lookup }) => { + const element = item[row]; + const parent = resources[list]; + const earlier = lookup.placed({ "<": item[order] }); + const before = earlier.length === 0 ? parent.firstElementChild : earlier.at(earlier.length - 1)[row].nextElementSibling; + if (before !== element) place(parent, element, before); +}); + +Finalizer.create({ name: "todos/removeRows", components: { read: [row] } }, item => { + item[row].remove(); +}); + +/** The todos feature's DOM systems, as one composite: added, it runs on every tick. */ +export const dom = System.create([createRows, bindRows, placeRows]); diff --git a/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts b/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts new file mode 100644 index 000000000..e1bba9fe9 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mutationsOf, part, record } from "../../../shared/elements/elements.test-helper.js"; +import { TodoEntry } from "./todo-entry.js"; +import { TodoList } from "./todo-list.js"; +import { TodoRow } from "./todo-row.js"; +import { define } from "../../../shared/elements/define.js"; + +define({ "todo-entry": TodoEntry, "todo-list": TodoList, "todo-row": TodoRow }); + +afterEach(() => { + document.body.replaceChildren(); +}); + +const createRow = (label: string, done = false): HTMLElementTagNameMap["todo-row"] => { + const row = document.createElement("todo-row"); + row.label = label; + row.done = done; + document.body.append(row); + return row; +}; + +describe("todo-row", () => { + it("renders its label and done state, reflecting done as an attribute", () => { + const row = createRow("Buy milk"); + expect(part(row, ".label", HTMLElement).textContent).toBe("Buy milk"); + expect(part(row, ".toggle", HTMLInputElement).checked).toBe(false); + expect(row.hasAttribute("done")).toBe(false); + row.done = true; + row.label = "Buy oat milk"; + expect(part(row, ".toggle", HTMLInputElement).checked).toBe(true); + expect(row.hasAttribute("done")).toBe(true); + expect(part(row, ".label", HTMLElement).textContent).toBe("Buy oat milk"); + }); + + it("isn't shown while hidden", () => { + const row = createRow("Buy milk"); + expect(row.checkVisibility()).toBe(true); + row.hidden = true; + expect(row.checkVisibility()).toBe(false); + }); + + it("drops writes of the value it has", () => { + const row = createRow("Buy milk", true); + expect(mutationsOf(row, () => { + row.label = "Buy milk"; + row.done = true; + })).toEqual([]); + }); + + it("dispatches todo-toggle on a click, keeping the done state it was given", () => { + const row = createRow("Buy milk"); + const seen = record(document.body, "todo-toggle"); + part(row, ".toggle", HTMLInputElement).click(); + expect(seen).toEqual([{ type: "todo-toggle", detail: null, target: row }]); + expect(row.done).toBe(false); + expect(part(row, ".toggle", HTMLInputElement).checked).toBe(false); + }); + + it("dispatches todo-delete", () => { + const row = createRow("Buy milk"); + const seen = record(document.body, "todo-delete"); + part(row, ".delete", HTMLButtonElement).click(); + expect(seen).toEqual([{ type: "todo-delete", detail: null, target: row }]); + }); + + it("edits on a double click: Enter and blur dispatch todo-edit, Escape cancels, and the label stays as given", () => { + const row = createRow("Buy milk"); + const seen = record(document.body, "todo-edit"); + const label = part(row, ".label", HTMLElement); + const edit = part(row, ".edit", HTMLInputElement); + label.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); + expect(edit.hidden).toBe(false); + expect(edit.value).toBe("Buy milk"); + expect(row.shadowRoot?.activeElement).toBe(edit); + edit.value = "Buy oat milk"; + edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(seen).toEqual([{ type: "todo-edit", detail: { label: "Buy oat milk" }, target: row }]); + expect(edit.hidden).toBe(true); + expect(label.textContent).toBe("Buy milk"); + + label.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); + edit.value = "Never mind"; + edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(seen).toHaveLength(1); + + label.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); + edit.value = "Blurred"; + edit.blur(); + expect(seen.map(event => event.detail)).toEqual([{ label: "Buy oat milk" }, { label: "Blurred" }]); + }); + + it("isn't draggable while editing, so its text can be selected, and ignores Enter while composing", () => { + const row = createRow("Buy milk"); + const seen = record(document.body, "todo-edit"); + part(row, ".label", HTMLElement).dispatchEvent(new MouseEvent("dblclick")); + expect(row.draggable).toBe(false); + const edit = part(row, ".edit", HTMLInputElement); + edit.value = "Buy 牛乳"; + edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true })); + expect(seen).toEqual([]); + edit.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(seen.map(event => event.detail)).toEqual([{ label: "Buy 牛乳" }]); + expect(row.draggable).toBe(true); + }); + + it("is focusable and draggable once connected, and can be created before it is", () => { + const row = document.createElement("todo-row"); + expect(row.hasAttribute("tabindex")).toBe(false); + document.body.append(row); + expect(row.tabIndex).toBe(0); + expect(row.draggable).toBe(true); + }); +}); + + +describe("todo-list", () => { + type Row = HTMLElementTagNameMap["todo-row"]; + + /** A list of four rows, a to d, on the page. */ + const createList = (): { readonly list: HTMLElementTagNameMap["todo-list"]; readonly rows: readonly [Row, Row, Row, Row] } => { + const list = document.createElement("todo-list"); + document.body.append(list); + const row = (label: string): Row => { + const created = document.createElement("todo-row"); + created.label = label; + list.append(created); + return created; + }; + return { list, rows: [row("a"), row("b"), row("c"), row("d")] }; + }; + + const press = (row: Row, key: "ArrowUp" | "ArrowDown", altKey = true): void => { + row.dispatchEvent(new KeyboardEvent("keydown", { key, altKey, bubbles: true })); + }; + + it("dispatches todo-move for Alt+ArrowUp and Alt+ArrowDown, and never moves children itself", () => { + const { list, rows } = createList(); + const [a, b, c, d] = rows; + const seen = record(document.body, "todo-move"); + press(c, "ArrowUp"); + press(a, "ArrowDown"); + press(b, "ArrowDown"); + // At either end there's nowhere to go, and without Alt the keys do nothing. + press(a, "ArrowUp"); + press(d, "ArrowDown"); + press(c, "ArrowUp", false); + expect(seen.map(event => event.detail)).toEqual([{ item: c, before: b }, { item: a, before: c }, { item: b, before: d }]); + expect([...list.children]).toEqual(rows); + }); + + it("skips hidden children when moving by keyboard", () => { + const { rows } = createList(); + const [a, b, c, d] = rows; + b.hidden = true; + d.hidden = true; + const seen = record(document.body, "todo-move"); + press(c, "ArrowDown"); + press(a, "ArrowDown"); + press(c, "ArrowUp"); + expect(seen.map(event => event.detail)).toEqual([{ item: a, before: null }, { item: c, before: a }]); + }); + + it("dispatches todo-move for a drop: before the child dropped on, or after it in its lower half", () => { + const { list, rows } = createList(); + const [a, b, c] = rows; + const seen = record(document.body, "todo-move"); + const drop = (item: HTMLElement, onto: HTMLElement, lower: boolean): void => { + const dataTransfer = new DataTransfer(); + item.dispatchEvent(new DragEvent("dragstart", { bubbles: true, composed: true, dataTransfer })); + const bounds = onto.getBoundingClientRect(); + const clientY = lower ? bounds.bottom - 1 : bounds.top + 1; + onto.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, composed: true, dataTransfer, clientY })); + onto.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, composed: true, dataTransfer, clientY })); + }; + drop(c, a, false); + drop(a, b, true); + expect(seen.map(event => event.detail)).toEqual([{ item: c, before: a }, { item: a, before: c }]); + expect([...list.children]).toEqual(rows); + }); +}); + + +describe("todo-entry", () => { + it("dispatches todo-add on Enter and clears itself, ignoring blank text; Escape clears", () => { + const entry = document.createElement("todo-entry"); + document.body.append(entry); + const seen = record(document.body, "todo-add"); + const input = part(entry, "input", HTMLInputElement); + input.value = "Buy milk"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(seen).toEqual([{ type: "todo-add", detail: { label: "Buy milk" }, target: entry }]); + expect(input.value).toBe(""); + input.value = " "; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + expect(seen).toHaveLength(1); + input.value = "Draft"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(input.value).toBe(""); + }); + + it("ignores Enter while composing", () => { + const entry = document.createElement("todo-entry"); + document.body.append(entry); + const seen = record(document.body, "todo-add"); + const input = part(entry, "input", HTMLInputElement); + input.value = "牛乳"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true })); + expect(seen).toEqual([]); + expect(input.value).toBe("牛乳"); + }); +}); diff --git a/packages/ecs/src/samples/todo/features/todos/elements/todo-entry.ts b/packages/ecs/src/samples/todo/features/todos/elements/todo-entry.ts new file mode 100644 index 000000000..646eb86fc --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/elements/todo-entry.ts @@ -0,0 +1,45 @@ +/** + * ``: the field a new todo is typed into. Enter dispatches `todo-add` with the text and clears the field, + * unless it holds only whitespace; Escape clears it. The draft is its own state. + */ + +const template = document.createElement("template"); +template.innerHTML = ` + +`; + +export type TodoEntryEvents = { + readonly "todo-add": CustomEvent<{ readonly label: string }>; +}; + +const listen = (host: HTMLElement, input: HTMLInputElement): void => { + input.addEventListener("keydown", event => { + if (event.key === "Escape") input.value = ""; + // Enter while composing text picks the composition. + if (event.key !== "Enter" || event.isComposing || input.value.trim() === "") return; + host.dispatchEvent(new CustomEvent("todo-add", { detail: { label: input.value }, bubbles: true, composed: true })); + input.value = ""; + }); +}; + +export class TodoEntry extends HTMLElement { + constructor() { + super(); + const root = this.attachShadow({ mode: "open", delegatesFocus: true }); + root.append(template.content.cloneNode(true)); + const input = root.querySelector("input"); + if (input === null) throw new Error("todo-entry's template has no input."); + listen(this, input); + } +} + +declare global { + interface HTMLElementTagNameMap { + "todo-entry": TodoEntry; + } + interface HTMLElementEventMap extends TodoEntryEvents {} +} diff --git a/packages/ecs/src/samples/todo/features/todos/elements/todo-list.ts b/packages/ecs/src/samples/todo/features/todos/elements/todo-list.ts new file mode 100644 index 000000000..3edf9adab --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/elements/todo-list.ts @@ -0,0 +1,93 @@ +/** + * ``: holds rows as its children, in the order they're placed. Dragging a child onto another, or + * Alt+ArrowUp and Alt+ArrowDown on a focused child, dispatches `todo-move` with the child and the one it should go + * before, null for the end, skipping hidden children; the list never moves children itself. + */ + +const template = document.createElement("template"); +template.innerHTML = ` + +

Nothing to do.

`; + +export type TodoListEvents = { + readonly "todo-move": CustomEvent<{ readonly item: HTMLElement; readonly before: HTMLElement | null }>; +}; + +const move = (list: HTMLElement, item: HTMLElement, before: HTMLElement | null): void => { + if (item === before) return; + list.dispatchEvent(new CustomEvent("todo-move", { detail: { item, before }, bubbles: true, composed: true })); +}; + +/** The list's child an event came from, or null. */ +const childOf = (list: HTMLElement, target: EventTarget | null): HTMLElement | null => { + for (let node = target instanceof Node ? target : null; node !== null; node = node.parentNode) { + if (node.parentNode === list) return node instanceof HTMLElement ? node : null; + } + return null; +}; + +const siblingOf = (node: Element, forward: boolean): Element | null => forward ? node.nextElementSibling : node.previousElementSibling; + +/** The nearest sibling not hidden, in the direction given, or null. Reading `hidden` forces no style recalculation. */ +const shownSibling = (item: Element, forward: boolean): HTMLElement | null => { + for (let node = siblingOf(item, forward); node !== null; node = siblingOf(node, forward)) { + if (node instanceof HTMLElement && !node.hidden) return node; + } + return null; +}; + +const listen = (list: HTMLElement): void => { + let dragged: HTMLElement | null = null; + list.addEventListener("dragstart", event => { + dragged = childOf(list, event.target); + if (dragged !== null && event.dataTransfer !== null) event.dataTransfer.effectAllowed = "move"; + }); + list.addEventListener("dragover", event => { + if (dragged !== null) event.preventDefault(); + }); + list.addEventListener("drop", event => { + const item = dragged; + dragged = null; + const target = childOf(list, event.target); + if (item === null || target === null) return; + event.preventDefault(); + const bounds = target.getBoundingClientRect(); + const after = target.nextElementSibling instanceof HTMLElement ? target.nextElementSibling : null; + move(list, item, event.clientY < bounds.top + bounds.height / 2 ? target : after); + }); + list.addEventListener("dragend", () => { + dragged = null; + }); + list.addEventListener("keydown", event => { + if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; + const item = childOf(list, event.target); + if (item === null) return; + event.preventDefault(); + if (event.key === "ArrowUp") { + const previous = shownSibling(item, false); + if (previous !== null) move(list, item, previous); + } + else { + const next = shownSibling(item, true); + if (next !== null) move(list, item, shownSibling(next, true)); + } + }); +}; + +export class TodoList extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true)); + listen(this); + } +} + +declare global { + interface HTMLElementTagNameMap { + "todo-list": TodoList; + } + interface HTMLElementEventMap extends TodoListEvents {} +} diff --git a/packages/ecs/src/samples/todo/features/todos/elements/todo-row.ts b/packages/ecs/src/samples/todo/features/todos/elements/todo-row.ts new file mode 100644 index 000000000..d87ce9ed6 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/elements/todo-row.ts @@ -0,0 +1,138 @@ +/** + * ``: one todo. Properties `label` and `done` drive it, and `done` is reflected as an attribute for styling; + * `hidden` hides it, as it does any element. It dispatches `todo-toggle`, `todo-delete` and `todo-edit`, and never + * changes its properties itself: whoever owns the todo writes them back. Editing is its own state, as focus is. + */ + +const template = document.createElement("template"); +template.innerHTML = ` + + + + +`; + +export type TodoRowEvents = { + readonly "todo-toggle": CustomEvent; + readonly "todo-delete": CustomEvent; + readonly "todo-edit": CustomEvent<{ readonly label: string }>; +}; + +const dispatch = (target: HTMLElement, type: K, detail: TodoRowEvents[K]["detail"]): void => { + target.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })); +}; + +/** The row's parts, found once in its shadow root. */ +type Parts = { readonly toggle: HTMLInputElement; readonly label: HTMLElement; readonly edit: HTMLInputElement; readonly delete: HTMLButtonElement }; + +const partsOf = (root: ShadowRoot): Parts => { + const toggle = root.querySelector(".toggle"); + const label = root.querySelector(".label"); + const edit = root.querySelector(".edit"); + const remove = root.querySelector(".delete"); + const found = toggle instanceof HTMLInputElement && label instanceof HTMLElement && edit instanceof HTMLInputElement && remove instanceof HTMLButtonElement; + if (!found) { + throw new Error("todo-row's template is missing a part."); + } + return { toggle, label, edit, delete: remove }; +}; + +/** Starts editing. The row isn't draggable meanwhile, so the text being edited can be selected. */ +const startEditing = (host: HTMLElement, parts: Parts): void => { + parts.edit.value = parts.label.textContent ?? ""; + parts.label.hidden = true; + parts.edit.hidden = false; + host.draggable = false; + parts.edit.focus(); +}; + +/** Leaves editing, dispatching the new label when `commit` is set. */ +const stopEditing = (host: HTMLElement, parts: Parts, commit: boolean): void => { + if (parts.edit.hidden) return; + parts.edit.hidden = true; + parts.label.hidden = false; + host.draggable = true; + if (commit) dispatch(host, "todo-edit", { label: parts.edit.value }); +}; + +const listen = (host: HTMLElement, parts: Parts, done: () => boolean): void => { + parts.toggle.addEventListener("change", () => { + // The owner writes `done` back; until then the checkbox shows the value it was given. + parts.toggle.checked = done(); + dispatch(host, "todo-toggle", null); + }); + parts.delete.addEventListener("click", () => dispatch(host, "todo-delete", null)); + parts.label.addEventListener("dblclick", () => startEditing(host, parts)); + parts.edit.addEventListener("keydown", event => { + // Enter while composing text picks the composition. + if (event.key === "Enter" && !event.isComposing) stopEditing(host, parts, true); + else if (event.key === "Escape") stopEditing(host, parts, false); + }); + parts.edit.addEventListener("blur", () => stopEditing(host, parts, true)); +}; + +export class TodoRow extends HTMLElement { + readonly #parts: Parts; + #label = ""; + #done = false; + + constructor() { + super(); + const root = this.attachShadow({ mode: "open" }); + root.append(template.content.cloneNode(true)); + this.#parts = partsOf(root); + listen(this, this.#parts, () => this.#done); + } + + /** Placing a row connects it again, so attributes are set only once. */ + connectedCallback(): void { + if (this.hasAttribute("tabindex")) return; + this.tabIndex = 0; + this.draggable = true; + } + + get label(): string { + return this.#label; + } + + set label(value: string) { + if (value === this.#label) return; + this.#label = value; + this.#parts.label.textContent = value; + } + + get done(): boolean { + return this.#done; + } + + set done(value: boolean) { + if (value === this.#done) return; + this.#done = value; + this.#parts.toggle.checked = value; + this.toggleAttribute("done", value); + } +} + +declare global { + interface HTMLElementTagNameMap { + "todo-row": TodoRow; + } + interface HTMLElementEventMap extends TodoRowEvents {} +} diff --git a/packages/ecs/src/samples/todo/features/todos/events.ts b/packages/ecs/src/samples/todo/features/todos/events.ts new file mode 100644 index 000000000..7db484f0d --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/events.ts @@ -0,0 +1,11 @@ +import { entityOf, entityOfElement } from "../../shared/attach.js"; +import { all, on, type Connect } from "../../shared/connect.js"; +import { addTodo, deleteTodo, editTodo, moveTodo, toggleTodo } from "./model.js"; + +export const connect: Connect = (world, root) => all( + on(root, "todo-add", event => void world.run(addTodo, { label: event.detail.label })), + on(root, "todo-toggle", event => void world.run(toggleTodo, { entity: entityOf(event) })), + on(root, "todo-edit", event => void world.run(editTodo, { entity: entityOf(event), label: event.detail.label })), + on(root, "todo-delete", event => void world.run(deleteTodo, { entity: entityOf(event) })), + on(root, "todo-move", event => void world.run(moveTodo, { entity: entityOfElement(event.detail.item), before: entityOfElement(event.detail.before) })), +); diff --git a/packages/ecs/src/samples/todo/features/todos/feature.ts b/packages/ecs/src/samples/todo/features/todos/feature.ts new file mode 100644 index 000000000..3579d61d4 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/feature.ts @@ -0,0 +1,14 @@ +import type { Feature } from "../../shared/feature.js"; +import { dom, list } from "./dom.js"; +import { TodoEntry } from "./elements/todo-entry.js"; +import { TodoList } from "./elements/todo-list.js"; +import { TodoRow } from "./elements/todo-row.js"; +import { connect } from "./events.js"; + +/** Todos: adding, toggling, editing, deleting and moving them, in the page's list. */ +export const todos: Feature = { + elements: { "todo-entry": TodoEntry, "todo-list": TodoList, "todo-row": TodoRow }, + resources: root => ({ [list]: root.querySelector("todo-list") }), + system: dom, + connect, +}; diff --git a/packages/ecs/src/samples/todo/features/todos/model.test.ts b/packages/ecs/src/samples/todo/features/todos/model.test.ts new file mode 100644 index 000000000..671b3ed86 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/model.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { Entity } from "../../../../index.js"; +import { addTodo, deleteTodo, editTodo, moveTodo, toggleTodo } from "./model.js"; +import { entityOf, labelsOf, todosOf, withTodos } from "./todos.test-helper.js"; +import { runReported } from "../../../../implementation/world/reports.test-helper.js"; + +describe("todos", () => { + it("adds todos at the end, trimmed, and ignores blank labels", async () => { + await using world = await withTodos(" milk ", "eggs", " ", ""); + expect(await todosOf(world)).toEqual([ + expect.objectContaining({ label: "milk", done: false }), + expect.objectContaining({ label: "eggs", done: false }), + ]); + }); + + it("toggles a todo", async () => { + await using world = await withTodos("milk", "eggs"); + const milk = await entityOf(world, "milk"); + await world.run(toggleTodo, { entity: milk }); + expect((await todosOf(world)).map(item => item.done)).toEqual([true, false]); + await world.run(toggleTodo, { entity: milk }); + expect((await todosOf(world)).map(item => item.done)).toEqual([false, false]); + }); + + it("edits a label, trimmed, and deletes a todo edited to blank", async () => { + await using world = await withTodos("milk", "eggs"); + await world.run(editTodo, { entity: await entityOf(world, "milk"), label: " oat milk " }); + expect(await labelsOf(world)).toEqual(["oat milk", "eggs"]); + await world.run(editTodo, { entity: await entityOf(world, "eggs"), label: " " }); + expect(await labelsOf(world)).toEqual(["oat milk"]); + }); + + it("deletes a todo, and ignores entities that aren't todos", async () => { + await using world = await withTodos("milk", "eggs"); + const milk = await entityOf(world, "milk"); + await world.run(deleteTodo, { entity: milk }); + expect(await labelsOf(world)).toEqual(["eggs"]); + const report = await runReported(world, deleteTodo, { entity: milk }); + expect(report.failures).toEqual([]); + await world.run(toggleTodo, { entity: milk }); + expect(await labelsOf(world)).toEqual(["eggs"]); + }); + + it("moves a todo before another, or to the end", async () => { + await using world = await withTodos("a", "b", "c", "d"); + await world.run(moveTodo, { entity: await entityOf(world, "d"), before: await entityOf(world, "b") }); + expect(await labelsOf(world)).toEqual(["a", "d", "b", "c"]); + await world.run(moveTodo, { entity: await entityOf(world, "a"), before: Entity.none }); + expect(await labelsOf(world)).toEqual(["d", "b", "c", "a"]); + await world.run(moveTodo, { entity: await entityOf(world, "c"), before: await entityOf(world, "d") }); + expect(await labelsOf(world)).toEqual(["c", "d", "b", "a"]); + // Moving before itself, or to where it already is, changes nothing. + await world.run(moveTodo, { entity: await entityOf(world, "d"), before: await entityOf(world, "d") }); + await world.run(moveTodo, { entity: await entityOf(world, "d"), before: await entityOf(world, "b") }); + await world.run(moveTodo, { entity: await entityOf(world, "a"), before: Entity.none }); + expect(await labelsOf(world)).toEqual(["c", "d", "b", "a"]); + }); + + it("numbers todos again when no order lies between two neighbours", async () => { + await using world = await withTodos("a", "b", "c"); + // Each move halves the gap before b, until no number is left in it. + for (let i = 0; i < 60; i++) { + const moving = i % 2 === 0 ? "c" : "a"; + await world.run(moveTodo, { entity: await entityOf(world, moving), before: await entityOf(world, "b") }); + } + const todos = await todosOf(world); + expect(todos.map(item => item.label)).toEqual(["c", "a", "b"]); + expect(new Set(todos.map(item => item.order)).size).toBe(3); + }); + + it("adds after the last todo, wherever moves left it", async () => { + await using world = await withTodos("a", "b"); + await world.run(moveTodo, { entity: await entityOf(world, "b"), before: await entityOf(world, "a") }); + await world.run(addTodo, { label: "c" }); + expect(await labelsOf(world)).toEqual(["b", "a", "c"]); + }); +}); diff --git a/packages/ecs/src/samples/todo/features/todos/model.ts b/packages/ecs/src/samples/todo/features/todos/model.ts new file mode 100644 index 000000000..0dfb441be --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/model.ts @@ -0,0 +1,80 @@ +import { Entity, System } from "../../../../index.js"; +import { Todo, done, label, order, orderAfter, ordered, todo } from "./data.js"; + +const text = { type: "string" } as const; + +/** Adds a todo at the end of the list, with its label trimmed; a blank label adds nothing. */ +export const addTodo = System.create({ + name: "todos/add", + input: { type: "object", properties: { label: text } }, + lookup: { ordered: { index: ordered, read: [order] } }, +}, ({ input, lookup, commands }) => { + const trimmed = input.label.trim(); + if (trimmed === "") return; + commands.spawn(Todo)({ [label]: trimmed, [done]: false, [order]: orderAfter(lookup.ordered()) }); +}); + +const one = { entity: Entity.schema } as const; + +export const toggleTodo = System.create({ + name: "todos/toggle", + input: { type: "object", properties: one }, + target: "entity", + components: { write: [done], has: [todo] }, +}, row => { + row[done] = !row[done]; +}); + +/** Gives a todo a new label, trimmed; a blank one deletes it. */ +export const editTodo = System.create({ + name: "todos/edit", + input: { type: "object", properties: { ...one, label: text } }, + target: "entity", + components: { write: [label], has: [todo] }, +}, (row, { input, commands }) => { + const trimmed = input.label.trim(); + if (trimmed === "") commands.despawn(row.entity); + else row[label] = trimmed; +}); + +export const deleteTodo = System.create({ + name: "todos/delete", + input: { type: "object", properties: one }, + target: "entity", + components: { has: [todo] }, +}, (row, { commands }) => { + commands.despawn(row.entity); +}); + +/** + * Moves a todo to just before `before`, or to the end for `Entity.none`, by giving it an order between its new + * neighbours'. When no number lies between them, every todo is numbered again. + */ +export const moveTodo = System.create({ + name: "todos/move", + input: { type: "object", properties: { entity: Entity.schema, before: Entity.schema } }, + lookup: { todos: { read: [order], has: [todo] }, ordered: { index: ordered, read: [order] } }, +}, ({ input, lookup, commands }) => { + const { entity, before } = input; + if (entity === before || lookup.todos(entity) === null) return; + const next = before === Entity.none ? null : lookup.todos(before); + if (before !== Entity.none && next === null) return; + const earlier = next === null ? lookup.ordered() : lookup.ordered({ "<": next[order] }); + if (earlier.length > 0 && earlier.at(earlier.length - 1).entity === entity) return; + const low = earlier.length === 0 ? (next === null ? 0 : next[order] - 1) : earlier.at(earlier.length - 1)[order]; + const high = next === null ? low + 2 : next[order]; + const middle = (low + high) / 2; + if (middle > low && middle < high) { + commands.add(entity, order, middle); + return; + } + const all = lookup.ordered(); + let position = 0; + for (let i = 0; i < all.length; i++) { + const row = all.at(i); + if (row.entity === entity) continue; + if (row.entity === before) commands.add(entity, order, position++); + commands.add(row.entity, order, position++); + } + if (next === null) commands.add(entity, order, position); +}); diff --git a/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts new file mode 100644 index 000000000..450a71847 --- /dev/null +++ b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts @@ -0,0 +1,34 @@ +import { Entity, System, workers, World } from "../../../../index.js"; +import { done, label, order, ordered } from "./data.js"; +import { addTodo } from "./model.js"; + +/** A todo as tests read it. */ +export type Listed = { readonly entity: Entity; readonly label: string; readonly done: boolean; readonly order: number }; + +const listed: Listed[] = []; +const readTodos = System.create({ name: "test/readTodos", observer: true, components: { read: [label, done, order] }, order: ordered }, row => { + listed.push({ entity: row.entity, label: row[label], done: row[done], order: row[order] }); +}); + +/** The todos, in list order. */ +export const todosOf = async (world: World): Promise => { + listed.length = 0; + await world.run(readTodos); + return [...listed]; +}; + +export const labelsOf = async (world: World): Promise => (await todosOf(world)).map(item => item.label); + +/** A world without a kernel pool, holding todos with these labels, in this order. */ +export const withTodos = async (...labels: string[]): Promise => { + const world = World.create({ [workers]: 0 }); + for (const text of labels) await world.run(addTodo, { label: text }); + return world; +}; + +/** The entity of the todo with this label. */ +export const entityOf = async (world: World, text: string): Promise => { + const found = (await todosOf(world)).find(item => item.label === text); + if (found === undefined) throw new Error(`No todo "${text}".`); + return found.entity; +}; diff --git a/packages/ecs/src/samples/todo/imports.test.ts b/packages/ecs/src/samples/todo/imports.test.ts new file mode 100644 index 000000000..c38863e1c --- /dev/null +++ b/packages/ecs/src/samples/todo/imports.test.ts @@ -0,0 +1,113 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** The sample's folder, the package root it imports the ECS from, and the implementation's index, which it never does. */ +const sample = dirname(fileURLToPath(import.meta.url)); +const packageIndex = resolve(sample, "../../index.js"); +const implementationIndex = resolve(sample, "../../implementation/index.js"); + +/** The features each feature builds on. */ +const builtOn: { readonly [feature: string]: readonly string[] } = { todos: [], filter: ["todos"], completion: ["todos"], generate: ["todos"] }; + +/** Every TypeScript file under a directory. */ +const filesOf = (directory: string): string[] => readdirSync(directory, { withFileTypes: true }).flatMap(entry => + entry.isDirectory() ? filesOf(join(directory, entry.name)) : entry.name.endsWith(".ts") ? [join(directory, entry.name)] : []); + +/** A path's segments within the sample, or null for one outside it. */ +const segmentsOf = (path: string): readonly string[] | null => { + const inside = relative(sample, path); + return inside.startsWith("..") || inside === "" ? null : inside.split(sep); +}; + +/** Whether a path is inside one of the sample's `elements` folders. */ +const inElements = (path: string): boolean => segmentsOf(path)?.slice(0, -1).includes("elements") ?? false; + +/** The feature a path is in, or null, as for `features/index.ts`, which exports them all. */ +const featureOf = (path: string): string | null => { + const segments = segmentsOf(path); + return segments !== null && segments[0] === "features" && segments.length > 2 ? segments[1] : null; +}; + +/** The specifiers a file imports or re-exports, statically or dynamically, and the modules its declarations augment. */ +const specifiersOf = (code: string): string[] => [ + ...code.matchAll(/(?:^|[\s;])(?:import|export)\s[^'"]*?\sfrom\s*["']([^"']+)["']/g), + ...code.matchAll(/(?:^|[\s;])import\s*["']([^"']+)["']/g), + ...code.matchAll(/import\s*\(\s*["']([^"']+)["']\s*\)/g), + ...code.matchAll(/declare\s+module\s+["']([^"']+)["']/g), +].map(match => match[1] ?? ""); + +/** Why `file` may not import `specifier`, or null when it may. */ +const broken = (file: string, specifier: string): string | null => { + const test = /\.test(-helper)?\.ts$|\.perf-browser-test\.ts$/.test(file); + if (!specifier.startsWith(".")) { + if (inElements(file)) return specifier === "vitest" && test ? null : "an element imports a package"; + return null; + } + const target = resolve(dirname(file), specifier); + if (inElements(file)) return inElements(target) ? null : "an element imports what isn't an element"; + const from = segmentsOf(file); + const to = segmentsOf(target); + if (to === null) return target === packageIndex || (test && target !== implementationIndex) ? null : "imports the package other than by its root"; + if (to[0] === "app" && from?.[0] !== "app") return "imports the app"; + if (from?.[0] === "shared" && to[0] === "features") return "shared code imports a feature"; + const feature = featureOf(file); + const imported = featureOf(target); + if (feature !== null && imported !== null && imported !== feature && !(builtOn[feature] ?? []).includes(imported)) { + return `${feature} imports ${imported}, which it doesn't build on`; + } + if (feature !== null && from?.[from.length - 1] === "model.ts" && to[to.length - 1] !== "data.js") return "a model imports what isn't data"; + return null; +}; + +/** Every import in the sample that breaks a rule, with the rule. */ +const violations = (files: readonly string[], codeOf: (file: string) => string): string[] => files.flatMap(file => + specifiersOf(codeOf(file)).flatMap(specifier => { + const reason = broken(file, specifier); + return reason === null ? [] : [`${relative(sample, file)}: ${specifier}: ${reason}`]; + })); + +describe("the todo sample's imports", () => { + it("keep to its layers: elements import nothing but each other, models only data, and no feature imports one built on it or the app", () => { + const files = filesOf(sample); + expect(files.filter(inElements).length).toBeGreaterThan(5); + expect(violations(files, file => readFileSync(file, "utf8"))).toEqual([]); + }); + + it("create worlds through World.create from the package root, never through the implementation", () => { + const at = (...path: string[]): string => join(sample, ...path); + expect(violations([at("app", "app.ts"), at("app", "app.test.ts")], () => `import { create } from "../../../implementation/index.js";`)).toEqual([ + `app/app.ts: ../../../implementation/index.js: imports the package other than by its root`, + `app/app.test.ts: ../../../implementation/index.js: imports the package other than by its root`, + ]); + }); + + it("find each kind of import that breaks a rule", () => { + const at = (...path: string[]): string => join(sample, ...path); + const imports = new Map([ + [at("features", "todos", "elements", "todo-row.ts"), ` + import { System } from "../../../../../index.js"; + import "@adobe/ecs"; + const later = import("../dom.js"); + import { on } from "../../../../other/elements/thing.js";`], + [at("features", "todos", "elements", "elements.browser.test.ts"), `import { it } from "vitest"; import { part } from "../../../shared/elements/elements.test-helper.js";`], + [at("features", "todos", "model.ts"), `import { filter } from "../filter/data.js"; import { list } from "./dom.js"; import { done } from "./data.js";`], + [at("features", "filter", "dom.ts"), `import { row } from "../todos/dom.js"; import { summary } from "../completion/dom.js";`], + [at("shared", "connect.ts"), `import { todos } from "../features/todos/feature.js";`], + [at("features", "generate", "events.ts"), `import { createApp } from "../../app/app.js"; import { thing } from "../../../implementation/world/create.js";`], + ]); + expect(violations([...imports.keys()], file => imports.get(file) ?? "")).toEqual([ + "features/todos/elements/todo-row.ts: ../../../../../index.js: an element imports what isn't an element", + "features/todos/elements/todo-row.ts: ../../../../other/elements/thing.js: an element imports what isn't an element", + "features/todos/elements/todo-row.ts: @adobe/ecs: an element imports a package", + "features/todos/elements/todo-row.ts: ../dom.js: an element imports what isn't an element", + "features/todos/model.ts: ../filter/data.js: todos imports filter, which it doesn't build on", + "features/todos/model.ts: ./dom.js: a model imports what isn't data", + "features/filter/dom.ts: ../completion/dom.js: filter imports completion, which it doesn't build on", + "shared/connect.ts: ../features/todos/feature.js: shared code imports a feature", + "features/generate/events.ts: ../../app/app.js: imports the app", + "features/generate/events.ts: ../../../implementation/world/create.js: imports the package other than by its root", + ]); + }); +}); diff --git a/packages/ecs/src/samples/todo/index.html b/packages/ecs/src/samples/todo/index.html new file mode 100644 index 000000000..5e26dd582 --- /dev/null +++ b/packages/ecs/src/samples/todo/index.html @@ -0,0 +1,51 @@ + + + + + + Todos + + + + +

todos

+
+
+ + +
+ +
+ + +
+
+ +

Double-click a todo to edit it. Drag it, or press Alt+↑ and Alt+↓, to move it.

+ + + diff --git a/packages/ecs/src/samples/todo/shared/attach.browser.test.ts b/packages/ecs/src/samples/todo/shared/attach.browser.test.ts new file mode 100644 index 000000000..d7caf033b --- /dev/null +++ b/packages/ecs/src/samples/todo/shared/attach.browser.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Entity, System, workers, World } from "../../../index.js"; +import { attach, entityOf, entityOfElement } from "./attach.js"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +/** Two live entities of a world, for elements to show. */ +const twoEntities = async (): Promise => { + const spawned: Entity[] = []; + await using world = World.create({ [workers]: 0 }); + await world.run(System.create({ name: "attachTest/spawn" }, ({ commands }) => { + spawned.push(commands.spawn([])({}), commands.spawn([])({})); + })); + const [first, second] = spawned; + if (first === undefined || second === undefined) throw new Error("Nothing spawned."); + return [first, second]; +}; + +/** The entity `entityOf` finds for an event of `type` dispatched at `target`, as a listener on the body reads it. */ +const foundFor = (target: EventTarget, type = "attach-test"): Entity => { + let found: Entity | null = null; + const listener = (event: Event): void => { found = entityOf(event); }; + document.body.addEventListener(type, listener); + target.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true })); + document.body.removeEventListener(type, listener); + if (found === null) throw new Error("The event didn't reach the body."); + return found; +}; + +describe("attach and entityOf", () => { + it("give the entity an element was attached to, returning the element", async () => { + const [first] = await twoEntities(); + const element = document.createElement("div"); + expect(attach(element, first)).toBe(element); + expect(entityOfElement(element)).toBe(first); + document.body.append(element); + expect(foundFor(element)).toBe(first); + }); + + it("find the first target on the composed path showing one, from where it was dispatched, through open shadow roots", async () => { + const [outer, inner] = await twoEntities(); + /** A host showing `outer`, with a shadow root holding a span showing `inner`, its button, and a paragraph. */ + const shadowed = (mode: ShadowRootMode) => { + const host = attach(document.createElement("div"), outer); + const root = host.attachShadow({ mode }); + const button = document.createElement("button"); + const plain = document.createElement("p"); + root.append(attach(document.createElement("span"), inner), plain); + root.firstElementChild?.append(button); + document.body.append(host); + return { host, button, plain }; + }; + const open = shadowed("open"); + expect([foundFor(open.button), foundFor(open.plain), foundFor(open.host)]).toEqual([inner, outer, outer]); + // A closed root's nodes aren't on the path a listener outside it sees. + const closed = shadowed("closed"); + expect([foundFor(closed.button), foundFor(closed.plain), foundFor(closed.host)]).toEqual([outer, outer, outer]); + }); + + it("give none for elements and events showing none, and for null", () => { + const element = document.createElement("div"); + document.body.append(element); + expect(entityOfElement(element)).toBe(Entity.none); + expect(entityOfElement(null)).toBe(Entity.none); + expect(foundFor(element)).toBe(Entity.none); + // After dispatch the composed path is empty. + const event = new CustomEvent("attach-test", { bubbles: true, composed: true }); + attach(element, Entity.none).dispatchEvent(event); + expect(entityOf(event)).toBe(Entity.none); + }); +}); diff --git a/packages/ecs/src/samples/todo/shared/attach.ts b/packages/ecs/src/samples/todo/shared/attach.ts new file mode 100644 index 000000000..6c926e6e2 --- /dev/null +++ b/packages/ecs/src/samples/todo/shared/attach.ts @@ -0,0 +1,32 @@ +import { Entity } from "../../../index.js"; + +/** The key an element holds the entity it shows under: only `attach` writes it. */ +const shown: unique symbol = Symbol("shown"); + +/** An element showing an entity. */ +type Attached = { readonly [shown]: Entity }; + +/** + * Records on `element` the entity it shows, for events from it, where the element is made for that entity, and + * returns it. Elements are never reused, so it's written once and lives as long as the element. + */ +export const attach = (element: E, entity: Entity): E & Attached => Object.assign(element, { [shown]: entity }); + +/** The entity a target shows, or none for one that shows none. */ +const shownBy = (target: EventTarget | Attached | null): Entity => target !== null && shown in target ? target[shown] : Entity.none; + +/** The entity an element shows, as `todo-move`'s `item` and `before` name them, or none for null or one that shows none. */ +export const entityOfElement = (element: Element | null): Entity => shownBy(element); + +/** + * The entity the first target on an event's composed path shows, from where it was dispatched outward, or none when no + * target shows one. Read while the event dispatches, as listeners do: the path is empty after. + */ +export const entityOf = (event: Event): Entity => { + const path = event.composedPath(); + for (let k = 0; k < path.length; k++) { + const found = shownBy(path[k]); + if (found !== Entity.none) return found; + } + return Entity.none; +}; diff --git a/packages/ecs/src/samples/todo/shared/connect.ts b/packages/ecs/src/samples/todo/shared/connect.ts new file mode 100644 index 000000000..4e7516bfa --- /dev/null +++ b/packages/ecs/src/samples/todo/shared/connect.ts @@ -0,0 +1,21 @@ +import type { World } from "../../../index.js"; + +/** Stops what `connect` started. */ +export type Disconnect = () => void; + +/** + * Connects a feature's events, dispatched within `root`, to runs of `world`. A run's failures are in its report, which + * the app logs; a rejected run goes unhandled, which the browser logs. + */ +export type Connect = (world: World, root: HTMLElement) => Disconnect; + +/** Stops every one of `disconnects`. */ +export const all = (...disconnects: readonly Disconnect[]): Disconnect => () => { + for (const disconnect of disconnects) disconnect(); +}; + +/** Listens for one event type on `root` until the returned function is called. */ +export const on = (root: HTMLElement, type: K, listener: (event: HTMLElementEventMap[K]) => void): Disconnect => { + root.addEventListener(type, listener); + return () => root.removeEventListener(type, listener); +}; diff --git a/packages/ecs/src/samples/todo/shared/elements/define.ts b/packages/ecs/src/samples/todo/shared/elements/define.ts new file mode 100644 index 000000000..55561f651 --- /dev/null +++ b/packages/ecs/src/samples/todo/shared/elements/define.ts @@ -0,0 +1,7 @@ +/** Elements by their tag names, as a feature lists those it creates or finds on the page. */ +export type Elements = { readonly [name: string]: CustomElementConstructor }; + +/** Defines each of `elements` not defined yet. */ +export const define = (elements: Elements): void => { + for (const [name, element] of Object.entries(elements)) if (customElements.get(name) === undefined) customElements.define(name, element); +}; diff --git a/packages/ecs/src/samples/todo/shared/elements/elements.test-helper.ts b/packages/ecs/src/samples/todo/shared/elements/elements.test-helper.ts new file mode 100644 index 000000000..e24621d55 --- /dev/null +++ b/packages/ecs/src/samples/todo/shared/elements/elements.test-helper.ts @@ -0,0 +1,28 @@ +/** What tests of elements share: no world, only the DOM. */ + +export type Recorded = { readonly type: string; readonly detail: unknown; readonly target: EventTarget | null }; + +/** Records the events of `types` that reach `target`, in order. */ +export const record = (target: EventTarget, ...types: (keyof HTMLElementEventMap)[]): Recorded[] => { + const seen: Recorded[] = []; + for (const type of types) target.addEventListener(type, event => seen.push({ type, detail: event instanceof CustomEvent ? event.detail : undefined, target: event.target })); + return seen; +}; + +/** The part of `host`'s shadow root `selector` finds, of `type`. */ +export const part = (host: Element, selector: string, type: abstract new () => E): E => { + const found = host.shadowRoot?.querySelector(selector); + if (!(found instanceof type)) throw new Error(`No ${selector}.`); + return found; +}; + +/** The mutations `change` makes to `host` and its shadow root. */ +export const mutationsOf = (host: Element, change: () => void): MutationRecord[] => { + const observer = new MutationObserver(() => {}); + observer.observe(host, { attributes: true, childList: true, characterData: true, subtree: true }); + if (host.shadowRoot !== null) observer.observe(host.shadowRoot, { attributes: true, childList: true, characterData: true, subtree: true }); + change(); + const records = observer.takeRecords(); + observer.disconnect(); + return records; +}; diff --git a/packages/ecs/src/samples/todo/shared/feature.ts b/packages/ecs/src/samples/todo/shared/feature.ts new file mode 100644 index 000000000..7709c28d3 --- /dev/null +++ b/packages/ecs/src/samples/todo/shared/feature.ts @@ -0,0 +1,20 @@ +import type { System, World } from "../../../index.js"; +import type { Connect } from "./connect.js"; +import type { Elements } from "./elements/define.js"; + +/** Values resources start at, as a world takes them. */ +export type Resources = Exclude[0], undefined>; + +/** + * What the app needs of a feature to compose it with the others. Its components, resources, indexes, rules and + * finalizers register as its modules load, so they aren't listed. + */ +export type Feature = { + /** The elements it creates or finds on the page, by their tag names, which the app defines. */ + readonly elements: Elements; + /** The fixed elements it drives, found within the page's root, as the resources it reads them from. */ + readonly resources: (root: ParentNode) => Resources; + /** Its DOM systems, as one composite added to every tick; null for none. */ + readonly system: System | null; + readonly connect: Connect; +}; diff --git a/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts b/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts new file mode 100644 index 000000000..be000d5bc --- /dev/null +++ b/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts @@ -0,0 +1,25 @@ +import { System, clock, type World } from "../../../index.js"; + +const waiting: (() => void)[] = []; + +/** Passes after each tick, the only passes that change the clock, and on its first. */ +const ticked = System.create({ name: "test/ticked", observer: true, resources: { changed: [clock] } }, () => { + for (const resolve of waiting.splice(0)) resolve(); +}); + +/** Lets `nextTick` resolve after `world`'s ticks: awaited once, before the world's first run. */ +export const watchTicks = async (world: World): Promise => { + world.add(ticked); + // Its first pass, which isn't a tick's. + await world.run(ticked); +}; + +/** Resolves after the next tick's observer systems ran: the DOM shows what the runs before it changed. */ +export const nextTick = (): Promise => new Promise(resolve => waiting.push(resolve)); + +/** Waits for a run, then for the tick after it. */ +export const settled = async (run: Promise): Promise => { + const ran = await run; + await nextTick(); + return ran; +}; diff --git a/packages/ecs/src/samples/transfers/build.test.ts b/packages/ecs/src/samples/transfers/build.test.ts new file mode 100644 index 000000000..58dd2899f --- /dev/null +++ b/packages/ecs/src/samples/transfers/build.test.ts @@ -0,0 +1,22 @@ +/// +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, expect, it } from "vitest"; +import { build, type Rollup } from "vite"; + +const packageRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const outDir = realpathSync(mkdtempSync(join(tmpdir(), "ecs-sample-"))); +afterAll(() => rmSync(outDir, { recursive: true, force: true })); + +it("the transfers sample builds as an application, with the world's worker and the loader's", async () => { + const output = await build({ + configFile: join(packageRoot, "vite.sample.config.ts"), root: join(packageRoot, "src/samples/transfers"), logLevel: "silent", + build: { outDir, emptyOutDir: true }, + }); + const items = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []); + const files = items.map(item => item.fileName); + expect(files.some(file => file.includes("world"))).toBe(true); + expect(files.some(file => file.includes("loader"))).toBe(true); +}, 30000); diff --git a/packages/ecs/src/samples/transfers/index.html b/packages/ecs/src/samples/transfers/index.html new file mode 100644 index 000000000..b624afb8a --- /dev/null +++ b/packages/ecs/src/samples/transfers/index.html @@ -0,0 +1,18 @@ + + + + + + Transferred Sprites + + + + +
Starting…
+ + + diff --git a/packages/ecs/src/samples/transfers/loader.ts b/packages/ecs/src/samples/transfers/loader.ts new file mode 100644 index 000000000..8b2d79b3d --- /dev/null +++ b/packages/ecs/src/samples/transfers/loader.ts @@ -0,0 +1,42 @@ +import { World } from '../../index.js'; +import { addSprite } from './sprites.js'; + +// The loader, on a worker: it joins the world through the port main sends, then encodes textures as images and decodes +// them, as it would images it fetched, moving each decoded bitmap to the world's thread in the run that adds its sprite. + +/** Textures loaded, one each `interval` milliseconds. */ +const count = 48; +const interval = 120; + +/** An encoded image: a disc of a hue of its own, numbered. */ +const encoded = async (index: number): Promise => { + const canvas = new OffscreenCanvas(48, 48); + const context = canvas.getContext("2d"); + if (context !== null) { + context.fillStyle = `hsl(${(index * 47) % 360} 80% 60%)`; + context.beginPath(); + context.arc(24, 24, 22, 0, Math.PI * 2); + context.fill(); + context.fillStyle = "#000"; + context.font = "bold 18px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(String(index + 1), 24, 25); + } + return canvas.convertToBlob({ type: "image/png" }); +}; + +const load = async (port: MessagePort): Promise => { + const world = World.join(port); + for (let index = 0; index < count; index++) { + const bitmap = await createImageBitmap(await encoded(index)); + // Moved, not copied: `bitmap` is detached here once the run is posted. + await world.run(addSprite, { bitmap, seed: index * 4 }); + await new Promise(resolve => setTimeout(resolve, interval)); + } +}; + +self.onmessage = (event: MessageEvent) => { + const port: unknown = typeof event.data === "object" && event.data !== null ? Reflect.get(event.data, "port") : null; + if (port instanceof MessagePort) void load(port); +}; diff --git a/packages/ecs/src/samples/transfers/main.ts b/packages/ecs/src/samples/transfers/main.ts new file mode 100644 index 000000000..b4960009a --- /dev/null +++ b/packages/ecs/src/samples/transfers/main.ts @@ -0,0 +1,46 @@ +import { System, World } from '../../index.js'; +import { attachSurface, stats } from './sprites.js'; + +/** + * The transfers sample's page. The world runs on a worker, drawing into the canvas this thread moves to it; a loader + * worker decodes textures and moves each to the world's thread; this thread, where the DOM is, joins the world and + * shows what it draws. + */ + +const overlay = (text: string): void => { + const element = document.querySelector("#stats"); + if (element !== null) element.textContent = text; +}; + +let latest = { sprites: 0, frames: 0 }; +// Added on this thread, it runs here after each of the world's frames that changed stats. +const show = System.create({ name: "transferSprites/show", observer: true, resources: { read: [stats], changed: [stats] } }, ({ resources }) => { + const { sprites, frames } = resources[stats]; + latest = { sprites, frames }; + overlay(`${sprites} sprites, each a texture the loader decoded and moved to the world's worker · frame ${frames}`); +}); + +const start = async (): Promise => { + const canvas = document.querySelector("canvas"); + if (canvas === null) return; + if (typeof canvas.transferControlToOffscreen !== "function") return overlay("This browser can't draw into a canvas from a worker."); + canvas.width = Math.floor(canvas.clientWidth * devicePixelRatio); + canvas.height = Math.floor(canvas.clientHeight * devicePixelRatio); + const world = new Worker(new URL("./world.ts", import.meta.url), { type: "module" }); + const loader = new Worker(new URL("./loader.ts", import.meta.url), { type: "module" }); + const forMain = new MessageChannel(); + const forLoader = new MessageChannel(); + world.postMessage({ ports: [forMain.port1, forLoader.port1] }, [forMain.port1, forLoader.port1]); + loader.postMessage({ port: forLoader.port2 }, [forLoader.port2]); + const joined = World.join(forMain.port2); + // Moved to the world's thread in the run's input: the canvas is drawn there from now on. + await joined.run(attachSurface, { canvas: canvas.transferControlToOffscreen() }); + joined.add(show); + /** For automated checks. */ + Reflect.set(globalThis, "sampleStats", async () => { + await joined.run(show); + return latest; + }); +}; + +void start(); diff --git a/packages/ecs/src/samples/transfers/sprites.ts b/packages/ecs/src/samples/transfers/sprites.ts new file mode 100644 index 000000000..fc5636010 --- /dev/null +++ b/packages/ecs/src/samples/transfers/sprites.ts @@ -0,0 +1,96 @@ +import { Component, Resource, System, Vec2, clock } from '../../index.js'; + +// The transfers sample's keys and systems, which every thread imports: the world, on a worker, draws sprites into the +// canvas main moved to it, each with a texture a loader worker decoded and moved to it. Values move through runs whose +// input holds them; an input field's schema `{ allOf: [] }` takes any value, typed `unknown`, which bodies narrow. + +export const texture = Symbol("transferSprites/texture"); +export const position = Symbol("transferSprites/position"); +export const velocity = Symbol("transferSprites/velocity"); +export const surface = Symbol("transferSprites/surface"); +export const stats = Symbol("transferSprites/stats"); + +declare module '@adobe/ecs' { + interface Components { + [texture]: { type: ImageBitmap; transfer: true }; + [position]: { type: Vec2 }; + [velocity]: { type: Vec2 }; + } + interface Resources { + [surface]: { type: OffscreenCanvas | null; transfer: true }; + [stats]: { type: { readonly sprites: number; readonly frames: number } }; + } +} + +/** A decoded texture, moved to the world's thread, where sprites are drawn. */ +Component.register(texture, { transfer: true }); +/** In pixels, and pixels per second. */ +Component.register(position, { type: Vec2.schema }); +Component.register(velocity, { type: Vec2.schema }); +/** The canvas main moved to the world's thread. */ +Resource.register(surface, { transfer: true }); +Resource.register(stats, { + type: { type: "object", properties: { sprites: { type: "integer" }, frames: { type: "integer" } }, default: { sprites: 0, frames: 0 } }, +}); + +const any = { allOf: [] } as const; + +/** From main: the canvas the world draws into. */ +export const attachSurface = System.create({ name: "transferSprites/attachSurface", input: { type: "object", properties: { canvas: any } }, resources: { write: [surface] } }, ({ input, resources }) => { + if (typeof OffscreenCanvas === "function" && input.canvas instanceof OffscreenCanvas) resources[surface] = input.canvas; +}); + +/** A deterministic number in [0, 1) for each seed. */ +const random = (seed: number): number => { + const value = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b) >>> 0; + return (Math.imul(value ^ (value >>> 13), 0xc2b2ae35) >>> 0) / 2 ** 32; +}; + +/** From the loader: a sprite with the texture it decoded, somewhere on the canvas, moving. */ +export const addSprite = System.create({ + name: "transferSprites/addSprite", input: { type: "object", properties: { bitmap: any, seed: { type: "integer" } } }, resources: { read: [surface], write: [stats] }, +}, ({ input, resources, commands }) => { + const canvas = resources[surface]; + if (canvas === null || typeof ImageBitmap !== "function" || !(input.bitmap instanceof ImageBitmap)) return; + const angle = random(input.seed) * Math.PI * 2; + const speed = 60 + random(input.seed + 1) * 120; + commands.spawn([texture, position, velocity])({ + [texture]: input.bitmap, + [position]: [random(input.seed + 2) * (canvas.width - input.bitmap.width), random(input.seed + 3) * (canvas.height - input.bitmap.height)], + [velocity]: [Math.cos(angle) * speed, Math.sin(angle) * speed], + }); + resources[stats] = { sprites: resources[stats].sprites + 1, frames: resources[stats].frames }; +}); + +/** Clears the canvas, once a frame. */ +const clear = System.create({ name: "transferSprites/clear", resources: { read: [surface], write: [stats] } }, ({ resources }) => { + const canvas = resources[surface]; + canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); + resources[stats] = { sprites: resources[stats].sprites, frames: resources[stats].frames + 1 }; +}); + +/** Moves each sprite, bouncing off the canvas's edges. */ +const move = System.create({ + name: "transferSprites/move", components: { read: [texture], write: [position, velocity] }, resources: { read: [surface, clock] }, +}, (row, { resources }) => { + const canvas = resources[surface]; + if (canvas === null) return; + const seconds = resources[clock].delta / 1000; + const [x, y] = row[position]; + let [dx, dy] = row[velocity]; + const right = canvas.width - row[texture].width; + const bottom = canvas.height - row[texture].height; + if ((x <= 0 && dx < 0) || (x >= right && dx > 0)) dx = -dx; + if ((y <= 0 && dy < 0) || (y >= bottom && dy > 0)) dy = -dy; + row[velocity] = [dx, dy]; + row[position] = [Math.min(Math.max(x + dx * seconds, 0), right), Math.min(Math.max(y + dy * seconds, 0), bottom)]; +}); + +/** Draws each sprite's texture where it is. */ +const draw = System.create({ name: "transferSprites/draw", components: { read: [texture, position] }, resources: { read: [surface] }, after: [clear, move] }, (row, { resources }) => { + const [x, y] = row[position]; + resources[surface]?.getContext("2d")?.drawImage(row[texture], x, y); +}); + +/** Every frame on the world's thread. */ +export const frame = System.create({ name: "transferSprites/frame", systems: [clear, move, draw] }); diff --git a/packages/ecs/src/samples/transfers/world.ts b/packages/ecs/src/samples/transfers/world.ts new file mode 100644 index 000000000..6fc978d16 --- /dev/null +++ b/packages/ecs/src/samples/transfers/world.ts @@ -0,0 +1,11 @@ +import { World, always } from '../../index.js'; +import { frame } from './sprites.js'; + +// The world, on a worker: main and the loader join it through the ports main sends, and it draws every frame. + +const world = World.create({ [always]: true }); +world.add(frame); +self.onmessage = (event: MessageEvent) => { + const ports: unknown = typeof event.data === "object" && event.data !== null ? Reflect.get(event.data, "ports") : null; + if (Array.isArray(ports)) for (const port of ports) if (port instanceof MessagePort) world.share(port); +}; diff --git a/packages/ecs/src/world.test.ts b/packages/ecs/src/world.test.ts new file mode 100644 index 000000000..b4d1a1966 --- /dev/null +++ b/packages/ecs/src/world.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { Component, Resource, System, World, workers } from "./index.js"; + +const count = Symbol("worldTest/count"); +const total = Symbol("worldTest/total"); +declare module "@adobe/ecs" { + export interface Components { + [count]: { type: number }; + } + export interface Resources { + [total]: { type: number }; + } +} +Component.register(count, { type: { type: "number" } }); +Resource.register(total, { type: { type: "number", default: 0 } }); + +const spawn = System.create({ name: "worldTest/spawn" }, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); +const raise = System.create({ name: "worldTest/raise", resources: { write: [total] } }, ({ resources }) => { resources[total] = 7; }); +const add = System.create({ name: "worldTest/add", components: { write: [count] } }, row => { row[count] += 1; }); + +describe("World.create", () => { + it("creates a world from the package root, the one import that names everything else", async () => { + await using world = World.create({ [workers]: 0 }); + await world.run(spawn); + await world.run(add); + let seen = 0; + await world.run(System.create({ observer: true, components: { read: [count] } }, row => { seen = row[count]; })); + expect(seen).toBe(2); + }); + it("rejects a key that isn't a resource, as worlds the implementation creates do", () => { + expect(() => Reflect.apply(World.create, undefined, [{ [count]: 1 }])).toThrow("isn't a registered resource"); + }); +}); + +describe("World.join", () => { + it("joins a world another thread shared, from the package root, and runs there", async () => { + await using world = World.create({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + await using joined = World.join(channel.port2); + await joined.run(raise); + let seen = 0; + await world.run(System.create({ observer: true, resources: { read: [total] } }, ({ resources }) => { seen = resources[total]; })); + expect(seen).toBe(7); + }); +}); diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md new file mode 100644 index 000000000..2031ffb18 --- /dev/null +++ b/packages/ecs/threads.md @@ -0,0 +1,1007 @@ +# Running one world across threads + +How one world runs across the main thread and workers: which thread owns structure, where objects live, how systems +on other threads stay consistent without stalling the world's thread, and the phases that build it. +Referenced from `design.md`. + +## Terms + +- **Struct:** numbers, booleans, entities, fixed-length tuples and structs of those. A struct has a fixed size and + lives in shared memory: columns, ticks, the entity table and resource cells of struct type, all in the arena + (`memory.ts`), readable and writable from any thread the schedule gives them to. A struct has no thread. +- **String:** any string, a union of string literals, either with `null`. A string lives in shared memory too, as a + record a handle names, which every thread reads, and has no thread; only the world's thread writes it (`design.md`, + "A string's storage"). +- **Object:** every other value: variable-length arrays, plain objects, sets and maps, and values that aren't + data, registered without a schema (DOM nodes, GPU objects, functions). Each object component and object resource lives + on one logical thread, and only the thread filling it reads or writes it. Objects are never copied between threads; + one declared `transfer: true` may be moved, never copied. +- **Logical thread:** a name in the augmentable `Threads` interface. `world` and `main` are built in. A joined thread + fills the logical threads it claims; the world's thread fills every one no joined thread claims. +- **Remote:** an object component, resource, system or finalizer whose logical thread a joined thread fills. Only + remote ones pay for anything below; an object of `main` in a world on the main thread isn't remote. + +## Model + +- **The world lives on the thread that creates it.** That thread, `world`, runs the loop, every sync point (plan, + finalize, apply), the kernel pool and the GPU device. Any thread can create a world; it stays + there for the world's life. +- **The loop is core.** A world ticks the systems `add` gave it, on the beats of its `cadence`, with its state as + resources: `cadence`, `always`, `wake` and `clock`. One world runs one loop. +- **There's no GPU thread.** `gpuDevice` lives on `world`, with the world, so GPU storage, kernels and render systems + never wait on another thread. +- **Objects default to `world`.** A declaration without `thread` is on `world`; one with it names a logical thread. + Libraries declare threads on components; the application decides which joined thread fills each. +- **The DOM rule:** a DOM `Node` lives on `main`, the window's thread, and its declaration says so: the types require + `thread: "main"` where the DOM library is loaded, and registration checks it on values it can see. +- **Keys are symbols,** so components, resources and traits never collide, but a symbol is a different value on each + thread. Threads agree on keys through the ids the plugin gives top-level `Symbol(...)` declarations, by which the + registry already takes the symbols of one id as one key; a key without one can't cross, so a joined thread's observer + system naming one fails to register. Ids name packages, not directories, so threads bundled apart agree on them, and + the plugin adds itself to `worker.plugins`, so worker bundles' keys get them too. +- **Systems, rules and finalizers are their values,** and names are optional labels, so a value is different on each + thread too. Threads agree on them through the ids the plugin gives top-level `System.create`, `System.render`, + `Rule.create`, `Finalizer.create` and `Finalizer.changes` bindings, composites included, by which the registry + already takes values of one id as one item. A threaded world rejects an item without one where it crosses threads: + a system or finalizer placed on a joined thread, and a system a joined thread runs, at join or at that run; a + composite crosses only if every member has an id. Rules run only at `world`'s sync points, so they never cross. A + kernel crosses as its chunk's url and the world's index for it, as pool workers load it now; kernel chunks keep + their exports' signatures, so every thread's import finds them. +- **Threads number what crosses locally.** A joined thread gives each key and item it sends its own number, and posts + the id once by message the first time; the world maps (thread, number) to its own. The world holds runs whose slots + use a number it can't map yet, in order, until that message arrives. A run of an id the world never imported + rejects. +- **Archetypes are lists of keys.** The index `Archetype.create` gives a list is for its own thread's lookups; + threads agree on an archetype through the world's numbers for its components. Commands and staged spawn batches + carry those numbers, never archetype indexes. +- **Inline is the same layout.** With every logical thread filled by the world's thread, storage and code paths are + those of today; nothing below adds work to a one-thread world. + +## Interface + +```ts +// Augmentable, as Components and Resources are. Built in: +export interface Threads { + world: true; // the thread that created the world + main: true; // the window's thread, where the DOM is +} + +// `thread` only on objects: a struct with `thread` fails in types and at registration. +declare module "@adobe/ecs" { + interface Components { + // A DOM Node lives on "main", and says so; the check resolves to no requirement where the DOM library isn't + // loaded. + [domNode]: { type: HTMLElement; thread: "main" }; + // `transfer: true` requires a Transferable type: ArrayBuffer, ImageBitmap, VideoFrame, OffscreenCanvas, + // MessagePort. It lives on `world`, where the GPU uploads it; a loader thread sets it by moving the value there. + [textureSource]: { type: ImageBitmap; transfer: true }; + } +} +// A value that isn't data is registered without a schema. +Component.register(domNode, { thread: "main" }); +Component.register(textureSource, { transfer: true }); + +// On the thread that holds the world: +const world = World.create(resources?); +world.share(port); // lets another thread join through the port: a MessagePort, a Worker, or a worker's own scope + +// On another thread, after importing the modules whose systems and finalizers run there. +const joined = World.join(port, { threads: ["main"] }); +``` + +`World.join`, from the package root as `World.create` is, returns the same `World` type; its port is `share`'s, and +the interface names no type for it, its port or its options. Its `run` works as on the world's thread, its `add` and `remove` take only observer +systems, which run there, `share` throws there, and its `dispose` disconnects this thread; disposing on the world's thread disposes the world, and joined threads' runs +reject from then on. `share` returns nothing: a joined thread's progress (`"joining"`, `"joined"` once the threads it +claims are its own, `"gone"` once they're released) and its claims are the implementation's own storage, which +`createWorld`'s `threads` gives for tests and benchmarks. A port is a `MessagePort`, a `Worker` or a worker's scope, +of the browser or node, and a joined thread's `dispose` closes only a `MessagePort`, never a worker or its scope. + +## Placement rules + +All derived from declarations and, for terminal systems, the plugin's analysis of their bodies; none is a flag. + +- **A system's thread** is the thread of every object component and object resource it names in `read`, `write`, + `added`, `changed`, `lookup`, `up` and resource `is`, and the key parts of the indexes it looks up through or orders + its rows by. Strings and structs don't place it. Components in `has` and `not` are structural, and read a shared + presence word, so they don't place it. With no objects, a kernel runs on the pool and any other system on `world`. + Render systems and `kernel: "gpu"` systems are on `world`, with `gpuDevice`. +- **Errors when declared:** objects of two threads in one system or finalizer, in types and when it's created, which + registers it. **Errors when joining:** a thread claimed twice; a claimed thread whose systems or finalizers that + joined thread hasn't imported (checked by id, once), or one of them without an id. +- **A terminal system** writes only objects of its own thread, issues no commands, writes no struct component or + struct resource, reduces into nothing, and looks nothing up, since a lookup reads the entity table the next sync + point rewrites. Commands are in every non-GPU system's context, so "issues no commands" is what the plugin proves from + the body (`compiler/terminal.ts`): its context absent, destructured by plain names other than `commands` without a + rest, or a name used only as `name.property` for other properties, and no `arguments` or `eval`; the plugin marks such + a body by wrapping it, as it gives ids. A system the plugin can't prove is not terminal. A tail's context has no + `commands`. DOM-writing systems and render systems are terminal. A remote terminal system that no system of a later + tier conflicts with runs as a tail (see Tails). Any other remote system is a blocking tier, and the tick's report + lists it under `warnings`. +- **Finalizers** run on the thread of the objects they read, after the sync point where a joined thread fills it. So + one on another thread than the world's, which a joined thread may fill, has no `commands` in its context, writes no + resources and declares no `lookup`, and `Finalizer.changes` watches and reads only the world's thread's values, in + types and at creation; `before`/`after` across threads is an error at creation. +- **Values for another thread's objects:** a command issued on one thread may give an object component of another + thread only its start or `null`, as tags the owning thread resolves, and a command issued on a joined thread gives a + string only its start. Any other value fails the command: a spawn's when + it's issued, an add's when its system's commands are queued on the world's thread, or when it's issued on a joined + one, where it drops and its system fails, so the issuing paths check nothing while no joined thread fills objects. + A `transfer` value reaches its own thread through a run's input instead (see Transfers). +- **`up` and `order` run only on `world`:** a system that declares either can't be placed on a joined thread, which + refuses the claim, or fails the system's registration once the thread is joined. So does one that writes a string, + which only the world's thread writes, and a lookup through an index with an object or string key part, which the + world keeps on its own thread; one keyed by structs is read where the system runs. + An index lookup's `changed: true` runs only on `world` too, observer systems' included, since the world decides from + its index whether the system runs. +- **Observer systems** are placed as other systems are, and run on their thread after the world's sync points (see + Observer systems on joined threads); a joined thread's own, which its `add` or `run` takes, run there whatever they + read, and read only structs, strings and the objects of the threads it fills. + +## Mechanisms + +### The loop + +- **Cadence.** `frame` beats on `requestAnimationFrame` where the world's thread has it, and on a timer otherwise; + `fixed` beats every `step` milliseconds, running at most `maxSteps` ticks when behind and dropping the rest. +- **Ticks and wakes.** A beat ticks only where `always` is set, `wake` (a reduced resource, `"min"`) is due, or a run + arrived since the last tick. A quiet loop schedules nothing, and a fixed loop builds no debt while quiet. The + clock's own writes never wake it. +- **The loop host** (`frame`, `timer`, `now`) can be given in `WorldOptions`, for tests and benchmarks. A back-to-back + driver, `backToBack()`, gives a `frame` that calls back through a `MessageChannel` post: a task, so worker messages + and `waitAsync` settle between ticks, which a microtask would starve, and unclamped, as `setTimeout(0)` isn't. +- **`run(system, input)`** is a transaction: runs are sequential in call order, each its own sync point, and each one + makes the next beat tick. +- **Runs and ticks have reports,** entities the world keeps on its thread: `Report.run`, a struct every thread reads, + counts the joined threads that took part; `Report.errors`, an object on the world's thread, is there only when + something went wrong. A thread that dies doesn't reject a run or a tick: its systems are in `skipped`, each with a + `"thread"` failure, and later tiers still run. A tier that waited for a joined thread is in the report's `warnings`; + a reused report holding errors already takes them in place, so a blocking tier every tick changes no archetype. +- **Observer systems** run on `world` after each run's and tick's sync point; those on joined threads are posted to them + in the same pass. They never make the loop tick, and a run of them alone doesn't + wake it. While a tick's tails run on, its observer systems run at + once with its values, unless one reads a report component, when the tick waits for its tails first so it sees the + tick's report. While tails run, a report's commands wait for the next sync point, which settles them, rather than + apply as a sync point of their own between runs. + +### Shared layout directory + +- **Header:** the arena's first block, at offset 0, allocated with the memory, which never moves. It holds the + memory's length in pages, the base offset and length of the archetype table, and the entity table's record. Its + offset travels in the `memory` message, to every pool a world starts. +- **Length:** `memory.ts` stores it in pages, since 4 GiB in bytes doesn't fit a `Uint32`, with `Atomics.store` right + after each `memory.grow`. A thread loads it at the start of each task, rebuilds its whole-buffer view of words over + `memory.buffer`, which serves the header and records, when it moved, and checks `memory.buffer.byteLength` covers + it. Another thread's growth reaches this thread's `memory.buffer` only once it handles the interrupt that growth + sends, and never when its memory object arrived in a message as the memory grew (V8), so a buffer short of the + stored length is first refreshed by `memory.grow(0)` (`coveringBuffer`), as are the views a joined thread makes at + offsets the world posts it. If it's still short, a pool worker marks itself broken, as a failed layout does, and + fails every later task, since typed arrays read out of bounds as `undefined` and drop such writes silently; a joined + thread fails the task whole: a word of its task block says so and its message why, writing nothing its views reach. +- **Archetype records:** the table holds, by the world's archetype number, the offset of that archetype's record. Each + record is allocated once, since an archetype's storages are fixed, and never moves; only the table grows. A record + holds `capacity`, the `entities` offset, a version, its flags and its count, and per storage its component number, its + flags (`ticked`, linear, remote with the joined thread's number), its column, tick and chunk-tick offsets and its + chunk count; a remote storage's column is its tags. Offsets are `Uint32`, since memory reaches 4 GiB. The resource + archetype has a record too, flagged, and the header holds the resource entity's index. +- **Entity-table record:** the `cursor` offset, which never moves, the free list's offset and capacity, the + `archetype`, `row` and `generation` offsets and capacity, and a version. The table's length is `cursor[2]`. Its + arrays move whenever they grow, even without memory growing, since a grown array's old block is reused. +- **Writes:** the world's thread keeps its JS fields as the source of truth on its hot paths. Growth and `startTicks` + bump an archetype's version and append it to a list of bumped archetypes; table growth in `materialize` and + `recycle` marks the entity table. The list is flushed into records at the end of each sync point, isolated ones + included, and wherever registration publishes now, those between sync points under the structure epoch while a joined + thread holds claims. Each record's version is stored last, with `Atomics.store`. +- **Reads:** a thread checks a record's version, one load, before using what it cached for that archetype, and keeps + per-archetype column views and kernel columns, rebuilt only when that version moves. The whole-buffer view serves the + header and records; columns stay views of their own, as kernels and layouts take them. On an entity-table version + change, a worker updates the `entities` object its spawners hold in place. +- **Messages:** only new components' schemas are posted, none for a value that isn't data, since a message closes the + pool's windows. A new archetype is found in the table, its record naming its component numbers, the first time a + task names it. +- **Counts:** pool tasks carry their rows; joined threads read counts. While a joined thread holds claims, archetypes + whose count changed are appended to a dirty list, by pair and by batch where the sync point changes them, and their + counts flushed with the rest; a new record carries its count. +- **Lookups:** WASM lookups keep their per-lookup directories (`directories.ts`), a match flag, each read column's + address and, with `changed`, each watched tick column's address per archetype, so a lookup searches nothing. JS + kernels' lookups on workers read archetype records. A kernel whose lookups by entity watch takes its `lastRun` last + in its lookup parameters, in both. + +### Task rings + +The pool's ring (`pool.ts`, `protocol.ts`) carries WASM kernel tasks, all numbers, with claimed splits; a JS kernel's +task, with its resources and input, is a message, which closes the pool's windows, and its commands come back through +its worker's command ring. Pool workers are nested workers of the world's thread by default; the main thread may create +them and pass their ports instead. Each ring has posted, done, epoch and signal words on their own cache lines, plus +payload words. Workers wait in a window with `Atomics.wait`. The world's thread, and the main thread, wait with +`Atomics.waitAsync`, or a posted message where that's unavailable. + +A joined thread's tasks go through its task block (`thread-tasks.ts`), since one is in flight at a time: the world posts +one and waits for it before the next tier or the sync point. Its words, each group on a line of its own: posted and +done; the command ring's base and capacity and the payload's and completion's offsets and capacities, which the world +sets while no task runs; and the ring's cursor and needed bytes, which the thread stores. The payload holds the task's +sequence, **the sync it's replayed through**, whether its input comes by message, and per system its index on the +thread, the tick it takes, its `lastRun`, its tier within the task and where its folds go. A joined thread never blocks +waiting: it waits asynchronously on the one signal word that both task posts and its runs' completions bump, so the +world can always reach it. The world spins about 20 µs for a task before it waits, and a thread that finished one +spins as long for the next, each about one wake-up's latency, since a tick's tasks follow each other closely. +Consecutive tiers whose systems are all on one joined thread go as one task, with no barrier between them, unless a +later one uses an index: it starts a task of its own, after the world refreshes the index for it, so it sees the +earlier tiers' writes. + +**Indexes across the pool:** a kernel's lookups through indexes read the index's arrays in the memory at addresses its +run's parameters give, refreshed by the world before each run, its WASM through the ring and its JS readers through +the task's message. A hash index is built again in phases (`index-build.ts`), each a task of a reserved kernel number +through the rings, its plan in the memory: workers claim fixed pieces of entity indexes or fixed partitions of the +slots, so the index doesn't depend on which worker ran what. A phase a worker failed runs again on the world's thread. + +**A joined thread returns to its event loop at least once a tick,** so its timers, rendering and promises' callbacks +run however fast the world ticks: the world bumps a run word in the log header once a run's or tick's tiers are done, +before its sync point and tails, and a thread holding claims takes a turn of its event loop rather than go on +synchronously once that word moved since its last turn, so after the tick's last task there, while the world applies +the sync point; any thread takes one after at most 2 ms of work, a task's own length aside. A wait that resolves +asynchronously is a turn already. A turn costs about a microsecond: a message to itself in a browser, and in node, +whose messages can arrive without its timers running, a wait on a word of its own that times out at once (a timed +wait in a browser takes a millisecond). The trade: about 2 µs a tick where ticks follow each other back to back +(`handoff` +15%, `tail prompt` +13% in node, +10% and +15% in chromium; `fused tiers` and `remote run` within noise); +none for ticks a frame apart, between which the thread waits anyway. + +### Thread ports + +A port is `{ post, onMessage, onDeath, terminate }` (`port.ts`) over a browser `Worker`, a node `worker_threads` +worker, or a `MessagePort` another thread lent; a worker talks over its own scope, `self` or node's `parentPort`, as a +channel, `{ post, onMessage }`. `terminate` is async. Node's modules are loaded through `process.getBuiltinModule`, +only on node, so browser bundles never load them, and the node worker's url isn't written as `new Worker(new URL(...))`, +so a browser build bundles one worker (`worker-platform.ts`). Death is a `Worker`'s `error` or `messageerror`, a node +worker's `error`, `messageerror` or `exit`, or, for a lent worker, what its owner reports or either `MessagePort`'s +`close` where the platform has it, whichever comes first; nothing polls, so a quiet world does no work. A browser +`Worker` ended from outside its port reports nothing, so a lent worker's owner reports the end of one it ends. + +- **Platforms:** a world's pool comes from a platform, `{ count, start }`: `count` is what the `workers` resource's + `null` resolves to, its hardware threads, `navigator.hardwareConcurrency` or `os.availableParallelism()`, minus the + world's thread, and `start` starts one worker, or returns null when it has no more, so a pool takes as many as it + gets. The platform's own is browser workers where `Worker` exists, nested ones on a worker, else `worker_threads`, + else none. `WorldOptions`' `pool` gives another, or null for none: `lentWorkers(lent)` for workers another thread + lent. +- **Lending:** the owning thread starts each worker, posts it the `MessagePort` it talks on as its first message, and + keeps a second channel to the thread it's lent to, on which it reports the worker's death and ends it when asked; a + lent worker is started once, so a pool resized past what was lent gets fewer workers. `terminate` asks the owner and + closes both ports once it answers, or once the owner's port closes. +- **Tests:** a Vitest global setup (`vitest.fixtures.ts`) builds every `*.fixture.ts` with the plugin, as `pnpm perf` + bundles benchmarks, and `worker.ts` as a second entry, `worker.js`, in one directory, since a `worker_threads` worker + doesn't go through Vitest's module runner. A fixture holds its cases' keys and systems, since a built fixture has its + own registry, and exports scenarios that return data; `*.cases.ts` asserts on them, over the built fixture in node + (`threads.test.ts`) and the source in chromium (`threads.browser.test.ts`). Other node tests run under the module + runner, where workers can't load the sources, so those with kernels pin `workers` to 0. + +### Command rings + +The world's thread keeps its own `CommandBuffer`s, as now. Every other thread has one ring in shared memory, with that +thread as its only writer, drained by the world's thread at the sync point. Commands are `despawn`, and `add` and +`remove` of one component or a list. + +- **Segments** (`command-ring.ts`): each task's is one block, on 8 bytes: a header with its merge tag and counts; its + commands as the columns of a `CommandBuffer` (entities, values' bytes, first components, component ids, offsets into + the values' bytes, kinds); its tagged values; its staged spawn batches, as blocks; and the reservation ranges its + block left unused. The worker builds the columns in arrays of its own, reused by every task, and copies them in at + the task's end, so the world drains a segment by taking views of its columns, decoding nothing but its tags. +- **Values:** structs are written as bytes in the target storage's layout, through each component's layout writer, + with numbers and f32 lane tuples written straight in. The world's `CommandBuffer` for a segment keeps those values + as bytes in the ring, at the offsets its column gives: `writeRange` copies them, by 32-bit word, and the plan's + references, finalizers' `commandValue` and holder-index changes decode only what they read. Any other value is a tag: + start, `null`, a number or a boolean, which the world resolves, start to the component's start; a value left out is + the start tag, struct or not; from phase 5, a slot, which names an object of the issuing thread's own logical thread, + kept there in a staging array with its entity and component. +- **Kernels:** a pool kernel's values are scalars, tuples, structs, `null` or `undefined` (its start), and kernels read + no object component (`compiler/subset.md`). So an object component gets a tag, and the compiler lists the components + a command or spawn gives a tuple or struct, which fails the kernel's registration for a component without a linear + layout; any other value one can't take fails the task. `FromWorker` carries only a task's completion, with its ring + extent and its spill, and its error. +- **Merge order:** each segment carries a merge tag: its system's run, by the tick it took, and its chunk, by archetype + and first row. The world consumes segments in its own schedule order, as it awaits chunks now, and checks each tag + against the task it posted rather than sorting, so the order never depends on which thread ran a chunk. WASM kernels + issue no commands, so claimed splits don't touch it. +- **Size:** a pool worker's ring words are in its control line: base, capacity, write cursor and the bytes its tasks + needed. It loads them at each task's end, when it writes its segment; the world resets the cursor, and regrows the + ring, after the sync point, when no task runs. A segment that doesn't fit what's left of the ring goes whole into a + private buffer carried in its completion message, and the worker adds what it needed to the needed word, which the + next sync point grows the ring to, at least doubling it. A ring starts at 64 KiB; a dead worker's is left to it, + since it may still write. +- **Spawns,** `spawn(components)(values)`, stay staged (`staging.ts`), not commands: each thread stages into private + batches per component set, in the target storages' layout, as the main thread and pool workers do now, and reserves + entities in blocks. At a task's end a worker copies each batch's rows into its segment as a block, after its + commands, then the reservation ranges its block left unused. The sync point commits blocks in merge order, right + after the world thread's own batches and before any command, as batches over the ring, and returns the unused ranges + to the free list. + +### Joining + +- **The handshake** (`sharing.ts`, `join.ts`, `joining.ts`): a joined thread's first message names the threads it + claims, whether it can wait with `Atomics.waitAsync`, and a Web Lock it holds while it lives, where the platform has + Web Locks, and with claims the ids of the systems and finalizers it imported; it posts nothing else until the lock is + held. The world refuses a thread claiming `world`, one thread twice, one another joined thread claims, or one a + registered system or a finalizer of the program runs on that the thread can't run: one without an id, naming a key + without one, declaring `up`, or one it didn't import, checked again between runs as its claims are made; it's refused + with its status `"gone"` and its runs rejecting. Else the world places the thread's control words and slots in the + memory, and writes `"joined"` and the claims. With claims, between runs, the claimed threads' object components and + object resources become the thread's (see Object columns on other threads), each announced by message with its id; it + places the thread's task block; it posts the memory, the offsets of its control words, its move log and its task + block, and its number; then the systems and finalizers on the claimed threads are placed there, each posted to the + thread with its index there, its id and the world's numbers for the keys it names. Claims are released when the + thread is gone, between runs, its objects coming back as each row's tag names them, `null` or the start, since a value + the thread staged or wrote was its own, and its systems and finalizers placed on the world's thread again, each system + keeping its `lastRun`. +- **A thread's end** is a `leave` from its `dispose`, its `MessagePort`'s `close`, a node worker's `exit`, or the + world's request for its Web Lock being granted, which happens however the thread ends, so a browser worker ended from + outside its port is heard of too, with nothing polled. Its status becomes `"gone"`; a thread that left has its control + words freed, one that died keeps them, since it may still be writing. +- **Control words,** each on a line of its own: the signal the thread waits on, which the world bumps when runs + complete, a task is posted or an observer post waits for it, beside an unread word the thread clears before it reads + what's new, so the world notifies only a thread that has read every earlier wake; the last run the thread posted; and + the last one the world completed. A thread whose unread word is set again once it read reads again rather than wait, + since that wake's notify may have found no waiter, and the next wake won't notify while the word is set. A wake sets + the unread word before it bumps the signal, so a thread that read the signal past a wake finds the word set, or + cleared it after, and never waits with the word set for a wake it read; bumped first, the world paused between the two + could leave it set so, and no later wake notified the waiting thread. A party that can't wait without blocking is + woken by a posted message instead. + +### Runs from joined threads + +`run(system, input)` from a joined thread writes a slot naming the system by the thread's number for its id, with a +sequence number: 64 slots of 256 bytes, slot `(sequence - 1) % 64`, the thread writing one only once the world completed +the run 64 before it, and holding later runs, in order, until then. Struct input of a leaf system's schema, at most 240 +bytes with every number an f64 so it crosses exactly, is written in the slot; other input is posted as a message tagged +with that sequence number, which the world waits for when it reaches the slot, as it waits for an id's message, so runs +land in call order. An input that can't be posted marks its slot, which the world completes without running. Each run +is its own sync point, as on the world's thread, and makes the next beat tick. The world writes the run's status in its +slot, done with the run's number beside it or rejected, and advances the completed word through the runs finished in +order; `run` resolves through it to that number, and a rejection is posted, the thread settling runs in order once it +has both. Its report, if any, is the world's, read on the world's thread. + +### Object columns on other threads + +By row on their thread, exactly as `storage.values[row]` is today, so iteration costs nothing more. The world's storage +for a remote object component or object resource holds no values: it holds each row's tag (`move-log.ts`), the +component's start, `null`, a value the thread's systems wrote, or a staged slot's id, in its column in shared memory, +which row copies copy, and it keeps ticks, the objects' only ones, stamped where the sync point stamps any and by the +thread's systems as they write, so the world's systems see changes and a rebuild reads them. A write there marks the +row's tag as the thread's own value. Each joined thread keeps a mirror of its object columns (`mirror.ts`), with +keys and starts; its systems' runners (`thread-systems.ts`) view the world's archetypes from the directory, with the +mirror's columns for its own objects, and their commands write to its ring; the plan and structure stay on `world`. + +**The move log.** For archetypes containing a remote object component, apply appends to the log of each joined thread +whose objects they hold, in apply order, 16-byte entries in a ring in shared memory: `move(fromArchetype, fromRow, +toArchetype, toRow)` between archetypes both holding its objects, `append(archetype, row, entity)`, `range(archetype, +row, count)` followed by its entities, two to an entry, `swap(archetype, lastRow, row)` (with `lastRow` equal to `row` +when the removed row was last), and `set(archetype, row, component, tag)`, each sync's first under a sync entry naming +its tick, which is the sync number. A row a move or an append fills starts each of the thread's objects it gains at +its start; a set gives it the value its tag names, its ticks the world's, stamped `changed`, and `added` where the +component starts in its row, re-adds within one sync point included, as the entry's added flag says. Every path that +moves or fills a row appends: `commitBatches` and `commitStaged`, `writeValues` for entries that stay, `arrive`, +`leave` and its `swapRemove`, `rederive`, and isolated sync points; `finalizeAll` at dispose moves none. The world +publishes "through position P, sync N" in the log's header after each sync point's entries, before the structure epoch +turns even. + +**Replay.** A thread replays lazily, only syncs published: before a task whose payload names sync S, or an observer +post, it processes each pending sync in order: its finalizers first, against values still at their pre-apply +rows, then its log. It copies the entries out, then checks the writer hasn't lapped it: the writer stores a limit 64 +entries ahead of where it writes before writing past it, and the copy holds if the limit is within the ring's size of +where it started. A staged slot that no entry of a sync it was for names was dropped, and is cleared without a +finalizer. An entry naming an archetype holding a component whose key the world hasn't named yet holds replay before +its sync until the message arrives. The ring holds max(4096, the rows of the archetypes holding the thread's objects) +entries, rounded up to a power of two and replaced at a sync point's start when the rows outgrow it; past that it +drops them, and a thread behind, or whose ring was replaced, rebuilds, at a task's head or under the structure epoch: +rows from each archetype's record, its `entities` and count, and values by each row's tag, a slot's from the staging +array while it's there, else, and for a value its systems wrote, the thread's own, kept from its old rows by entity. +Tags make it exact: a start or `null` given after a slot or a write wins, as it did. A thread keeps an entity column +per archetype it holds objects of (8 bytes a row, updated by replay). + +### Systems on other threads + +A system whose thread a joined thread claims is placed there (`remote-systems.ts`): the world's runner for it stands in +the schedule, and the thread runs it as written (`thread-systems.ts`). A blocking tier on a joined thread is a task in +its task block, posted before the tier's own systems start on the world's thread, which waits for it in system order; +consecutive tiers all on that thread are one task, which stops after a tier with a failure, as the world stops. + +- **Ticks:** the world gives each of a task's systems the next tick and its `lastRun`, and takes the tick as its + `lastRun` once the thread says it ran: the thread checks conditions, which may read its object resources, so one + skipped leaves its tick unused. +- **Rows:** the thread views each archetype from its record, struct columns and ticks in shared memory and its own + objects in the mirror's columns, made again when the record's version or the mirror's columns move; rows are the + world's accessor rows over those views (`rows.ts`), and change filters read the shared ticks. Resources are the + resource archetype's row; lookups locate entities in the shared entity table, and a lookup's `changed` reads the + shared ticks against the system's `lastRun`. A GPU kernel's whole-column stamp reaches the thread as row ticks, which + the world lifts to it before the task: for the components the system filters on in its matched archetypes, and for + those its lookups watch in every archetype. A registration that starts ticks flushes the layout directory, with or + without a pool, so the thread sees them before its next task. +- **Commands and spawns** write into the thread's command ring, a segment per system, as a pool worker's do, with the + merge tag of its tick and position. A value given the thread's own object is staged in its mirror under a slot whose + tag the segment carries; a key the world didn't give the thread a number for is named by the thread's own number, + its id posted first, which the world maps as it drains the segment. Spawns stage into batches per component set and + reserve entities in blocks from the shared cursor. The world drains each segment in its system's place. +- **Completion:** per system, whether it took its tick, ran its rows and failed, its segment's extent, and its folds, + which the world combines in system order, as it combines its own; then the done word. A message follows only with a + failure's error, a segment that spilled past the ring, or the thread's numbers for keys named for the first time. + Input comes by message, before the task. +- **A thread that's gone** settles its task in flight: its systems are skipped, each with a `"thread"` failure, and + later tiers still run. +- **Lookups through indexes:** for each, the world keeps a record in the memory, posted with the system's setup, and + writes into it before each task where the index's arrays are, its counts and ticks, and the system's `lastRun`; the + thread points views of the same structures at them before the system runs, so its lookups are the world's own code, + call sites with their own lists included. An index without a key is a sorted table there as on the world's thread, + its entries' stride the table's. The record is freed with the system's other holdings. +- **Removal:** each system placed on a thread has a removed word there, a line of its own in the memory, kept with its + index there and posted with its setup. `world.remove` sets it before it returns, and the thread checks it, one + atomic load, before it runs each system of a task, a tail's included, so a task posted before the removal, in flight + or fused with later tiers, runs no more of it; the world posts no later task holding it, even one whose tiers it + read before a body removed it while it waited for the thread: it gathers a task's systems, skipping removed ones, + after the task's last await. The world then posts + `unplace` with the sequence of the last task it posted, and once the thread finished that task it drops what it holds + for the system, its runner and its tail block's view, and answers `unplaced`: so no task names the index after the + thread forgot it. The world frees the system's tail block once that tail's last task settled, and places another + system at the index only once the answer arrived, as stop lists hold an entity's index until each thread finalized it. + A task's completion, rather than the next sync point, is what makes the drop safe: a thread left with no system gets + no more tasks, and replays nothing until one comes. + +### Finalizers on other threads + +A finalizer whose thread a joined thread claims is placed there, as systems are, each posted to the thread with its +index there, its id and the world's numbers for its keys (`finalizers.ts`, `activate.ts`); one created after the claim +is placed when it activates, and one the thread can't name, without an id or naming a key without one, doesn't activate +and is reported once in a sync point's failures. Once the thread is gone, they run on the world's thread again. + +- **Stop lists** (`stop-lists.ts`): before apply, the world's thread writes one block per sync point with stops for that + thread: per finalizer, in the world's finalizer order, a group of its stops as columns: each entity, its pre-apply + archetype and row, the tag of each object it reads, and a copy of each linear struct it reads, in that component's + layout, and of each string's record, by its handle, let go once the thread acknowledged the block: it may finalize + sync points after the row's own record was let go and freed. The world copies a column at a time, finding each column's storage once per run of an archetype. Blocks are + linked in sync order, the first from the log header's stops word, each block's next offset stored once that block is + whole, so a thread reads only whole blocks of published syncs. A block is freed once the thread acknowledged a later + one, since the thread reads the next offset from the newest block it ran. The world wakes the thread after each sync + point that published stops for it, so a thread with no task or observer post still finalizes. +- **Running them** (`thread-finalizers.ts`): the thread runs a sync's stop list as it replays that sync, at its sync + entry, before the sync's entries apply, or at replay's end for a sync with no entries for it; its finalizers read the + linear structs from the block, constants as they are, and its objects by each stop's tag: the start or `null` as + tagged, any other at the stop's row in its mirror. After its log lapped, it runs them before it rebuilds, reading a + value it wrote or a settled slot's by entity in its old rows, and a live slot's from its staging array. Resources are + read where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer + whose setup hasn't arrived holds replay before its sync, as a component without a key does; one this thread never + imported fails each of its groups. What its finalizers throw is posted, and a later run's report lists it as a + `"finalizer"` failure. +- **The guarantee:** each such finalizer runs exactly once per stop, on its thread, before that thread's next task or + observer post after the sync point and before the entity's index is reused, seeing its declared struct and string + reads and its thread's objects as of before apply. +- **Held indexes:** the index of an entity despawned with such stops is held, counted by index for each thread that + hasn't acknowledged it, apart from `released`, so the leak scan never sees it and the free list never has it; the + sync point after a thread acknowledges releases it once no thread holds it. A thread that's gone has its held indexes + returned, and each finalizer with stops it never acknowledged is a `"thread"` failure in the next sync point's + report. A thread finalizes what was published before it leaves. +- **Dispose:** once the world is disposed, a joined thread runs the stop lists published, then its finalizers for every + row they match, as the world's thread runs its own. +- **Restriction:** a finalizer on a thread other than `world` issues no commands, writes no resources and looks + nothing up, wherever it runs, so the world's thread never waits for one. Kept open, to revisit when a use case needs + it: (a) allow commands and resource writes, applied at the next or a later sync point, with lookups read under the + structure epoch; (b) the world waits for remote finalizers inside the sync point, exact timing for about a 10 µs + round trip per sync point with such stops. + +### Observer systems on joined threads + +An observer system runs on a joined thread when that thread's `add` or `run` took it, as its own, or when the threads it +names objects of are ones that thread claims (`joined-observer.ts` on the world's thread, `thread-observer.ts` there). +One placed by its objects is named there by its id and its keys' ids, as systems placed there are: without them, it +fails to register, its run rejecting and an added one's failure in a later report. + +- **Its own:** the thread declares it to the world once, by its own number for it, with what it reads by its numbers for + its keys and the ids of the indexes it looks up through, or, for an index without an id (one made in a function), its + declaration by those numbers, which the world makes an index like, one per declaration for every thread, kept while + the world lives; it needs no id, since its value runs only there. The world stands a system of its own for it, which + it registers, adds and removes as the thread says, and runs as its runs: so its keys and indexes are the world's, its + ticks start, and its runner, which never runs, finds what it matches. Once its value is collected there, the thread + says so and the world forgets it; and once the thread is gone, every one of its own. +- **Its block:** a tail block (see Tails) and a line of its own: posts so far, what the thread did with the last one, + its removed word, the run it's owed to, the sync of the thread's log it's as of, and the tick it takes and the + `lastRun` it filters by. The thread is posted its index there, its block, the world's numbers for its keys and the + records of its index lookups, written before each post as for systems on joined threads. +- **Posting:** after each run's and tick's sync point, in the observer pass, the world posts one whose conditions hold + and that has no change filters, or one whose filter may pass: a resource it filters on changed since its `lastRun`, or + a chunk of an archetype it matches was stamped, or its column stamped by a GPU kernel, which reaches the thread as row + ticks. One a run named, from either thread, is posted after each sync point until it ran. Posting reads back what the + GPU wrote that it reads, or the indexes it looks up through hold by, then refreshes those indexes, then writes its + block, holding its progress word at an opening value while it does, its posts word last and then opening it, so a + thread that saw the last post's posts word claims nothing until the post is whole; then it wakes the thread once for + the pass, unless the thread hasn't read the last wake. +- **Posts holding copies:** one that reads only struct resources, with no rows or lookups, posts copies of what it + reads (a string's handle would outlive its record there, so one reading a string resource posts as row systems do): its head's flags, as of the sync point, and its resources' values, in its block's last line when they fit, as + they do for up to 80 bytes, or a snapshot of its own; it opens as copied, so the thread reads only its block. Its + snapshot's words are written once, its flags only when they change, and its work's end is new for each post, so the + thread's done word from the last needs no reset: a post writes its block's first and last lines. It has two blocks. + The next post releases the last: one no claim reached it cancels, one the thread ran it takes the outcome of, which + the thread stores once it ran, reading nothing of the post after; one the thread is running it takes the outcome of as + its flags say, which is what the thread does with it, and it goes into the other block, whose post is done, since the + thread runs one post at a time. So only the newest is ever unclaimed, and neither writers, sync points nor posts wait + for one. +- **Running:** the thread replays its objects through the newest sync first, then claims the post as a tail claims its + rows, by adding to its progress word, so the claim and the world's cancel are one order; a claim finding it cancelled + marks the posts word read before it handled, which is that post's, and a claim of it the word read after; once + claimed, a post stays as it is until its done word reaches its end, or, holding copies, until it says what the thread + did, the run it's owed to read first. It runs the system with the tail's code, from where its rows are or the copy, + its lookups and index lookups reading the shared memory, and stores what it did, then its done word. A thread whose + objects the world rebuilt past the post's sync gives the claim back, and one owed to a run asks to be posted again + between runs; one holding copies reads none of its objects, so they aren't replayed for it. It returns to its event + loop once it ran a post before it goes on. +- **Writers:** posts holding copies need nothing of them. Before a tier or the world writes what another post reads (a + resource or a struct component it reads or filters on, in its rows, the clock or a GPU readback), the world copies the + rest of its rows and resources, as for a late tail, once; before one writes what its lookups read or watch, or the key + parts of the indexes they go through, it waits for a post the thread claimed to finish and cancels one it hasn't, past + the cancels below. +- **Sync points:** each, and what moves rows outside one (registration, resources added between runs, a GPU device + change, a thread joining or leaving), leaving posts holding copies alone, waits for every post the thread claimed and + cancels the rest, which the next post, for the newest sync, replaces: a thread that fell behind runs once, for the + newest. After four posts of a system cancelled in a row, writers and sync points wait for the next one instead, so a + thread slower to claim than the world is to write still runs it at least once in five sync points; at a frame's + cadence the thread claims well before. A post owed to a run that a move outside a sync point cancelled is posted again + between runs. +- **Ticks and reports:** a post takes a tick after its sync point, which becomes its `lastRun` once the thread ran it. + What a body throws is a `"system"` failure in a later run's report, named by the system's label on its thread, a + joined thread's `run` of its own included, which resolves once a post for it ran there, or once it was removed before + one did; a `run` of several of its own resolves to the last one's number. +- **Removal:** on the thread, `remove` stops it at once, from its next row on, and the world removes the system it + stands; on the world's thread, as other systems placed on joined threads: its removed word is set, which the thread + checks before it claims a post, and the thread drops it and says so. Its block and records are freed once the thread + said so and no post is live, or the thread left. +- **Counts:** while one is placed, the directory's counts are kept current, as while a thread holds claims. + +### Structure epoch + +One shared word, the layout directory header's fourth, kept while a joined thread holds claims: odd from the start of +each sync point that applies anything, before `materialize`, until its directory writes and logs' publishing at the +end, a window that includes commits of staged spawns and any GPU readback, and around flushes between sync points. +It turns odd by an atomic add, so later writes can't be seen before it, and even by a store, since only the world writes +it. Threads that read entity locations outside a task, observer posts and rebuilds, read while it's +even and check it again after, retrying on a change; the closing check is an atomic add of 0, so every read before it +completes first. A reader that finds it odd counts itself in the header's waiters word and waits on the epoch, which +the world notifies when it turns even only while a reader waits, rather than spinning. The guarded words (`archetype`, +`row`, `generation`) are read with `Atomics.load`. Values are never guarded by it: scheduled reads are ordered by +tiers, and observer posts read as of one sync point. + +### Tails + +Tails at the end of tick N sit just before the head of tick N+1, and ordinary scheduling applies across that boundary +(`tails.ts`, the thread's in `thread-systems.ts`). A tick posts its tails after its sync point, those of each joined +thread as one task, in schedule order, once that thread's last task finished, so a tail reads the tick's values as of +its sync point. A tier in N+1 that writes what a tail reads waits for the tail's reads, which end when its body ends: a +struct component it reads or filters on, or a struct resource it reads, conditions or filters on, written by a system, +a reduction, a GPU readback, or the world's own clock. A sync point counts as a write to everything a tail reads, from +its `materialize` on, since growth frees blocks the same sync point may reuse; one reading no rows and no struct +resources, only its own thread's objects, reads nothing there, so sync points don't wait for it. A run is a transaction, so it waits for +its tails before it resolves, with their failures in its report. + +- **Claims.** A tail's work is numbered: position 0 is its head, which checks its conditions and resolves its + resources, and positions from 1 are its matched archetypes' rows in order. It claims positions in chunks of 64 with + `Atomics.add` on a progress word in its block, and stores the position it read through after each chunk; the world + stored its end when it posted it. +- **Fallback snapshot.** Sometimes a conflicting writer is ready before the tail has finished its reads, as when main + is late. The world's thread then evaluates the tail's head as the tail would, copies the declared linear struct reads + of the passing rows from the position the tail reached, with their position, archetype, row and entity, and its + linear resources, a string's handles among them, into a tail buffer, a column at a time; exchanges the progress word for a sentinel; and waits until + the tail is done through what it had claimed, at most one chunk, spinning briefly before it waits. A claim that + returns the sentinel reads the rest from the copy, rows from its own position on. The values are the same either + way, so this stays deterministic. A prompt main never copies. A string's handle in the copy names its record as of + the copy, which outlives the tail: a tick posts its tails once the last tick's are done, and a run waits for them, so + a tail ends before the second sync point after the one it reads, and a record let go is freed only then. The copy lets the world's work after its writer + overlap the tail: where main's tail and that work together outlast a frame or a fixed step though each fits, ticks + keep the cadence, 16.7 ms a tick at 60 Hz in node and chromium against 20.2 with the writer waiting for the tail, and + back to back `tail overlap` takes 2.53 ms a tick against 4.70 for `tail overlap waiting`, in node. Where the + world's work comes before its writer, or the two fit a beat, waiting costs the same (perf.md, "Tried and not kept"). +- **The tail's own objects** are read where they are: while a tail is in flight, the thread's replay stops at the sync + it names, which the world stores in the log header as it posts it, so the next sync point's moves wait in the log + until the tail finishes. Rows read from the copy take their objects from the mirror at the copy's rows, or, once its + log lapped and it rebuilt past that sync, by entity. Objects it writes from the copy go back to the mirror after its + body; the world marks their tags the thread's own when it copies, and stamps them changed at the tail's tick, as the + task's completion names them by entity, before the thread's next task. +- **What waits for a tail to finish:** the next task on its thread, and what moves rows a tail may still read outside + a sync point: registration, resources added between runs, a GPU device change, and joined threads joining or leaving. +- **Report.** A tick ends in two steps: its tiers and sync point, which release the world's queue so N+1 can start, and + its report, written once after its tails, and every earlier tick's, so their failures are in it; while tails run, its + commands go to the next sync point. A tick whose systems read reports waits for the last tick's tails first. + +### Transfers + +A `transfer: true` value goes from a joined thread to its component's thread through `run(system, input)`: the value +is in the input, and the system, which writes the component, runs on that thread (`transfers.ts`, `join.ts`, +`sharing.ts`). + +- **What moves:** every Transferable value among the input's fields and the elements of fields that are arrays, found + as the run is written; an input with none is posted as before. Moved values are detached on the issuing thread. +- **Where to:** the joined thread filling the leaf system's thread, from the claims the world announced; else, for a + system on `world`, a composite, or a thread the issuing thread doesn't know is claimed yet, the world's thread, in + the input's message with its transfer list. A joined thread running a system it fills keeps the input where it is. +- **Channels:** once a thread joins, the world gives it and each other joined thread, one of the two filling objects + with its claims made, the two ends of a `MessageChannel`, with the other's number and claims. A thread that's gone + is announced to those it was paired with, whose channel to it closes. +- **Acknowledgement:** the run's slot names the thread its input moved to. That thread keeps the input by the issuing + thread and the run's sequence and tells the world it has it; the world holds the slot, and that thread's later + runs, until then, as it holds one for an input's message, so runs land in call order and the world's thread never + waits on delivery. The run's task names the input instead of carrying it, and the task takes it there. +- **Failures:** a run whose input moved to a thread that's gone, before or after the world started it, rejects. Once + the run finishes, an input no task took is released where it is, as when the system fails to register. +- **Release:** a value whose command drops is released on its thread: an add of the world's thread's at the sync + point, and a staged slot of a joined thread's once it replays that sync point, which the world wakes it for. + Releasing closes an `ImageBitmap`, `VideoFrame`, `AudioData`, `MessagePort` or `RTCDataChannel`, cancels or aborts + a stream, and leaves an `ArrayBuffer`'s or an `OffscreenCanvas`'s memory to the collector. +- **Types:** an input schema can't name a Transferable type yet; a field declared `{ allOf: [] }` takes any value, + typed `unknown`, which the body narrows. + +### Without cross-origin isolation + +Memory isn't shared, so the world runs inline, and `share` and `join` throw. + +## Cost + +| Common operation | Cost | +|---|---| +| Iterating rows, struct or object, on any thread | as today: no added load per row | +| A quiet loop | nothing: no tick, no delivery, workers parked | +| A run | its own sync point, in call order; the next beat ticks | +| A sync point with growth, other threads joined | one directory record per changed archetype; a message only for new archetypes or components | +| A task on any thread | one load per archetype it touches, of its record's version | +| A tier on a thread other than `world` | one task posted and waited for; consecutive tiers on that thread are one: `handoff` 11.5 µs a tick of five, `fused tiers` 7.5 µs a tick of one, in node | +| A blocking tier on main | main's event-loop latency, which the report warns about | +| A terminal tail on main, prompt | one post; nothing copied; the next tick waits only if main is late at a conflict: `tail prompt` 18.4 µs a tick of a 1,000-row tail, in node | +| A terminal tail on main, late at a conflict | a copy of the declared struct reads of its unclaimed passing rows, a run of rows at a time, and a wait of at most one chunk: `tail late snapshot 10K` 6.7 ns a row of a vec3 and a mat4, in node | +| A late tail and the world's work after its writer, together over a beat | the two overlap: `tail overlap` 2.53 ms a tick of a 2.5 ms tail and 2 ms of work, against 4.70 for `tail overlap waiting`, in node | +| Commands and staged spawns from a worker kernel or joined thread | ring writes; the private spill and its message only past the ring's size | +| `run` from a joined thread | one slot and notify; resolves through a word | +| An observer system on a joined thread, per sync point | a check of its filters, a post into its block and at most one notify per thread; one reading only struct resources posts copies of them and is never waited for: `joined observer resource` 4.3–5.0 µs a tick, in node; otherwise the next writer copies what it hasn't read, the next sync point waits only for a post in progress: `joined observer 10K` 0.41–0.51 ms | +| Structural change touching archetypes with remote objects | one log entry per row moved, replayed once on that thread, `log replay` 21 ns a move in node | +| A thread rebuilding after its log dropped entries | a pass over its archetypes' rows, `log rebuild 100K` 3.2 ns a row in node | +| Despawning entities with remote finalizers | stop-list copy of declared reads, a column at a time; the world's thread doesn't wait: `despawn objects 10K` 83 ns a despawn in node | +| A transferred asset | one message, loader to its thread, and one acknowledgement to the world; one run: `transfer` 134 µs a 1 MB ArrayBuffer and a 1024 by 1024 ImageBitmap, in chromium | + +## Memory + +- **Directory:** a header; a table of record offsets; a record per archetype, 24 bytes plus 32 bytes per storage. +- **Rings:** per pool worker, a task ring; per joined thread with claims, a task block of four lines with its payload + and completion, sized to the largest task; and per thread, a command ring sized to the largest tick's commands and + staged spawn rows, from 64 KiB, grown at sync points to the high-water word. +- **Move log:** a 64-byte header and 16 bytes per entry, max(4096, the thread's rows) rounded up to a power of two; on + the world's thread, 4 bytes of tag per row of each remote storage, beside its ticks; on the joined thread, its columns + and ticks, and 8 bytes per row of the archetypes it holds objects of, for its entity column. +- **Joined threads:** per thread, its control words and 64 run slots, 16 KiB; per pair of joined threads one of which + fills objects, a `MessageChannel`; per index a system was placed at there, a 64-byte line for its removed word, kept + for the index's next system. +- **Observer systems on joined threads:** per system, a block of four lines, a record per index lookup, and, once a + writer copied what it reads, a snapshot as a tail's; for one reading only struct resources, two blocks, and two + snapshots of its resources when they're over 80 bytes. +- **Tail buffers:** only once a fallback snapshot was taken: passing rows times their declared struct reads, kept for + the next. +- **Stop lists:** per sync point with stops, a 16-byte header, and per finalizer 16 bytes and per stop 16 bytes, 4 per + object read and its linear reads' bytes, each column on 8 bytes, kept until a later block is acknowledged; and on the + world's thread, 4 bytes per entity index up to the highest held. + +## Phases + +Each phase leaves the package shippable with every test passing, and starts interface first: type changes with red +then green type tests, then runtime with red then green runtime tests. Each phase ends with `pnpm perf --record` and +`pnpm perf:browser --record`; listed benchmarks must not regress beyond noise. + +### Built: threads in declarations, the loop, and the pool + +- `Threads`, `thread`, `transfer` and the DOM rule in types, with registration's checks; a system's and finalizer's + thread derived, and two threads rejected, when it's created. With every thread filled by `world`, nothing changes at + runtime. +- Ids: the plugin gives top-level keys and `System.create`, `System.render`, `Rule.create`, `Finalizer.create` and + `Finalizer.changes` bindings ids, in worker bundles too; the registry takes symbols or values of one id as one key or + item. Nothing rejects a key or item without one yet. +- The loop, inline: `add`, `cadence` with `maxSteps`, `always`, `wake`, `clock`, `report`, beats on animation frames or + timers, and the `workers` resource sizing the pool at sync points. `World.Report` has no `"thread"` failure kind. +- The pool: WASM kernel tasks through per-worker rings with claimed splits, JS kernel tasks by message; kernel spawns + staged into batches per component set on the main thread and workers, returned per chunk and committed in merge + order, with entities reserved in blocks whose unused positions the sync point returns; `terminate` waits for workers + to leave their windows before releasing the pool's control state to the arena. + +### Built: phase 1, shared layout directory + +The directory as above, without counts or the resource archetype (`layout-directory.ts`): workers read archetype and +entity-table layout from it, and `publish.ts` posts only new components. WASM lookups keep their directories. +`WorldOptions` gains the loop host as `loop`, and `backToBack()` is the `MessageChannel` driver. + +- Interface: none. Contract: none. +- Tests: the existing worker browser tests; a test that grows 100 archetypes across sync points and checks workers' + kernels see every row, with no layout message after the one creating them; one that grows the entity table between + runs and checks workers' lookups and spawns use the new table, with no message; one that grows memory between runs + and checks workers rebuild their views; a worker whose memory is shorter than the byte-length word fails its tasks. +- Must not regress: `sync point one spawn`, `run idle`, `run 50 idle`, `create`, `churn`, `spawn bulk kernel`, and in + chromium `move workers`, `follow workers`, `chain workers`, `sparse changed workers`, `spawn bulk kernel workers`, + `up depth 10 workers`. +- New, in chromium: `sync point grow 100 archetypes main` and `workers`: 100 archetypes, each filled untimed to its + capacity, then one spawn into each timed as one sync point, followed by a kernel over them in the workers variant so + workers take up the layout; repeated for a fixed number of doublings, keeping the fastest. +- New, in node and chromium: `loop tick idle`: ticks with nothing changed, driven back to back by the `MessageChannel` + loop host with `always` set, counted by an observer system filtering on the clock; N = 10,000 ticks. + +### Built: phase 2, thread ports and node workers + +`pool.ts` takes ports and `worker.ts` its scope's channel, as above, so real-thread tests and benchmarks run in node, +and pool workers can be nested workers of a world on a worker, or workers the main thread lent. A port's terminate is +async, as `pool.terminate` is, which still releases control state only once every windowed worker has left. Worker +counts come from the port's platform, not `typeof Worker`. A ring task naming the idle kernel, -1, runs nothing: the +`wake` benchmark's round trip. A registration that fails waits for its other kernels' loads to settle before it rolls +back, so none finishes after it and leaves workers' kernels behind the main thread's. + +- Interface: none. Contract: none. +- Tests: the worker browser tests' cases (workers, the layout directory, the pool and its size, dispatch, reductions, + observed stamps, `up`), moved into fixtures, in node and chromium; a worker killed mid-task from outside, by ending it + in node and by its owner in both, fails that run and every later task on it, and `dispose` finishes; a world on a + worker gives the main thread's results with nested workers and with workers main lent it; a failed registration + keeps workers' kernels in step whichever of its kernels fails to compile. +- Must not regress: every `workers` benchmark. +- New: in node, `move workers`, `follow workers`, `spawn bulk kernel workers` and `sync point grow 100 archetypes + workers`, with node's environment measuring every worker's copy bandwidth; `wake` (a notify from the world's thread to + a waiting worker and back, N = 10,000), node and chromium. Node's other benchmarks pin `workers` to 0, so they go on + measuring the world's thread. + +### Built: phase 3, command rings for the pool + +Worker kernels write their commands into their worker's ring as a `CommandBuffer`'s columns, with merge tags, values +as bytes, tags for the rest, and their staged spawn batches as blocks with their unused reservation ranges; the spill of +a segment past the ring's size and the needed word; `CommandBuffer`'s byte values, which `writeRange` copies and the plan's references, finalizers and holder-index +changes decode. `FromWorker` carries a task's completion, ring extent and error, and a spill. The world's own commands, +the sync point's commit of staged rows and its plan's order are unchanged. Kernel setups carry only linear components' +starts, the rest being the world's to resolve, and `terminate` waits for every worker that ran a task by message, as +for windows, since rings are in the control state. + +- Interface: none. Contract: a kernel giving a tuple or struct to a component without a linear layout fails at + registration. +- Tests: the existing merge tests, commands and staged spawns at 0, 1, 2 and 7 workers, in node and chromium; a task + that overflows its ring spills in order, the ring grows at the sync point and the next run doesn't spill; a failed + task's commands and spawns before its failure still apply; unused reservation ranges return to the free list once; + object components get their start, `null` or a number; a kernel giving a tuple to an object component fails at + registration. +- Must not regress: `emit`, `churn`, `sync point one spawn`, `spawn bulk kernel`, `despawn bulk kernel`, `add tag + kernel`, `remove tag kernel`, `spawn bulk kernel main`, `spawn bulk kernel workers`. +- New: `emit main` and `emit workers` (`emit`'s kernel, an add for a tenth of 1M rows), node and chromium. + +### Built: phase 4, joining + +`share`, `join`, the handshake with its claims and Web Lock, local numbering of keys and +items, remote `run` through run slots, sequence numbers and completion words, the resource archetype in the directory, and the structure epoch's word, which readers take up in phase 5a. Every logical +thread is still filled by `world`; a claim is recorded and checked against claims already made. + +- Interface: `world.share(port)`, returning nothing, its port an inline type; `join` and its options are the + implementation's, inferred from it. +- Contract: a joined thread's `run` of a system without an id, or of one the world never imported, rejects; a + thread claimed twice can't join; `add` and `share` throw on a joined thread; without cross-origin isolation `share` + and `join` throw; `share` and `join` are public. +- Tests: in node and chromium, a worker joining a world here and this thread joining a world on a worker, through + `MessagePort`s and through a worker and its own scope, with the keys and systems of one id imported on both threads; + runs with struct and object input landing in call order; a joined thread that dies is `"gone"`; runs of a system + without an id or never imported reject; a thread claimed twice is refused. On one thread through a `MessageChannel`: + slots overflowing in order, an input that can't be posted, runs rejecting after the world's dispose; world-only + components rejected in systems and commands; `share` and `join` throwing without cross-origin isolation; the epoch and + the resource record. +- Must not regress: `run idle`, `run 50 idle`, `sync point one spawn`, `loop tick idle`. +- New: `remote run` (an empty transaction from a joined thread, N = 1,000). + +### Built: phase 5a, remote object columns and the move log + +Claimed threads fill their object components: object columns by row on their thread (`mirror.ts`), staged slots with +their entity, component and sync, the world's storages holding each row's tag and ticks in their place (`remote.ts`), +the move log over every apply path with its added flags (`move-log.ts`), its published-through +words, replay with its lap check, and rebuild from the directory's counts and the tags; directory +counts. The structure epoch and counts are kept only while a joined thread holds claims, and the epoch notifies only +waiting readers. A claim whose systems or finalizers exist is refused until 5b and 5c, which the joined thread's +commands and runners come with; until then a slot is staged only through the mirror's `stage`, and the world names it in +a command buffer as a thread's ring will. + +- Interface: none. Contract: commands may give another thread's object components only their start or `null`: a + spawn's other value throws when issued, an add's drops at its system's end, which fails; systems on the + world's thread can't read them; when the thread is gone, they come back to the world's thread, `null` or the start. +- Tests: a property test of random spawns, despawns, adds and removes, single and list forms, through the commands + world-thread systems get, on archetypes holding objects on another thread, with values that thread stages through + its staging array, removed again in the same sync point now and then, before and after world commands in merge + order, comparing its values and ticks with an inline world's after every sync point by a stable id component, with no + pool workers, replaying the log and with the log forced to drop; a dropped slot settled without a value; the structure + epoch odd while a sync point applies; counts in the directory; in node and + chromium, a worker watching an entity whose objects change and move archetype every tick while the world ticks back + to back, every value's objects agreeing with its structs; on one thread through a `MessageChannel`, the + thread's objects at their starts and `null`, commands and kernels giving them other values failing, + systems here unable to read them, a claim refused for a thread whose systems or finalizers exist, and + the objects coming back once the thread is gone. +- Must not regress: every benchmark, inline. +- New: `log replay` (10,000 swap-removes and moves per sync point, time per move); `log rebuild 100K` (a forced + rebuild over 100,000 rows). + +### Built: phase 5b, blocking tiers + +Systems on claimed threads run there (`remote-systems.ts`, `thread-systems.ts`): runners over the world's archetypes as +the directory gives them and the thread's objects in its mirror, accessor rows shared with the world's thread's +(`rows.ts`), resources there, object resources of claimed threads included, lookups, change filters over the shared +ticks, conditions, reductions' folds in the completion, and commands and spawns into the thread's command ring, values +for its own objects staged as slots, keys the world gave it no number for by its own. Tasks go through each joined +thread's task block, one in flight, consecutive tiers on one thread fused, both sides spinning briefly before they wait; +`"thread"` failures and `warnings`. Claims check by id that the joined thread imported the systems placed on it, and +systems are placed on the world's thread again, keeping their `lastRun`, once it's gone. The objects' ticks are the +world's storages' alone, which the thread's writes stamp, marking the row's tag its own, so a rebuild keeps the value. A +joined thread reads again rather than wait while its unread word is set, which lost wakes before. + +- Interface: the `"thread"` failure kind in `World.Report`'s `failures` and the `report` resource's schema; `warnings` + in `World.Report`, each `{ kind: "blocking", name, message }`, its type inline, in the schema too; both type-tested. +- Contract: a blocking tier is listed in its run's or tick's `warnings`; a system on a claimed thread runs there, and a + joined thread's claim is refused for a system there it can't run, one it didn't import, one without an id or naming a + key without one, or one declaring `up`; a system whose thread is gone is in `skipped` with a `"thread"` failure, later + tiers running; a command on a joined thread giving another thread's object a value other than its start or `null` + drops, failing its system, and a spawn throws; the world's thread can't read a joined thread's object resources. +- Tests: on one thread through a `MessageChannel`, systems on a joined thread giving an inline world's values, reports + and resources over ticks, fused and mixed tiers, a run with input, a failing run and a condition; a value the thread's + systems wrote kept once its log lapped and it rebuilt; commands that can't cross; systems skipped once their thread is + gone, then run on the world's thread. In node and chromium, the same against a joined worker; a worker ended mid-task; + a claim refused for a system the worker never imported; ticks of world systems while the worker never yields; 30K + ticks of a system on the worker back to back, which stopped once a wake was lost. +- Must not regress: every benchmark, inline. +- New: `handoff` (10 tiers alternating `world` and a joined worker, empty systems, N = 1,000 ticks), 11.5 µs a tick in + node and 13.2 in chromium; `fused tiers` (10 consecutive tiers on a joined worker), 7.5 and 9.4 µs. + +### Built: phase 5c, remote finalizers + +Finalizers on claimed threads run there (`stop-lists.ts`, `thread-finalizers.ts`): placed as systems are, with their +setups posted, one created after a claim placed when it activates; stop lists written before apply, a column at a +time, one block per sync point linked in sync order, and the thread woken; finalizers run as the thread replays each +sync, before its entries apply, or before it rebuilds once its log lapped, against its objects by tag and entity; +replay held before a sync whose finalizer's setup hasn't arrived; "finalized through" acknowledged in the log header; +indexes of despawned entities held, counted by index, until every thread finalizing them acknowledged; held indexes +returned and unacknowledged stops reported once the thread is gone; what was published finalized before a thread +leaves, and every match once the world is disposed. Claims check by id that the joined thread imported the finalizers +placed on it. + +- Interface: a finalizer on another thread than the world's has no `commands` in its context, and writes no resources + and looks up nothing, in types and at creation; a changes finalizer watching or reading another thread's values is + rejected in types and at creation; finalizers hold any body, as systems do, their parameters checked where created. +- Contract: such finalizers run after the sync point, under the guarantee above, their failures in a later run's + report; a finalizer ordered with one on another thread is rejected at creation; a claim is refused for a finalizer + there the joined thread can't run, without an id, naming a key without one, or not imported; one created after the + claim that the thread can't name doesn't activate, reported once; stops of a thread that's gone are `"thread"` + failures; once the world is disposed, a joined thread finalizes every row its finalizers match. +- Tests: types and creation rejecting what can't run on another thread; on one thread through a `MessageChannel`, an + inline world's stops, with structs as before the sync point applies, the thread's objects at their starts, `null`, + written or staged, and its object resource, after its log lapped too; a finalizer placed by its object resource alone; + an index reused only once its stop was finalized (red without holds); failures there in a later report; finalizers + created after the claim, one without an id reported once; every match finalized there once the world is disposed; + stops published finalized when the thread leaves, then finalizers on the world's thread. In node and chromium, the + same stops against a joined worker; a worker that never yields holding indexes, which are reused and its stops + reported once it's ended. +- Must not regress: every benchmark, inline. +- New: `despawn objects 10K` (a sync point despawning 10K entities whose objects a joined worker fills, a finalizer + there reading the object and a vec3), 83 ns a despawn in node, against 77 with the finalizer run inline. + +### Built: phase 6, tails + +Terminal systems proven by the plugin (`compiler/terminal.ts`) and run as tails after their tick's sync point on their +joined thread (`tails.ts`, `tail-blocks.ts`, `thread-systems.ts`): claims in chunks, conflicts resolved at later tiers, +the clock, GPU readbacks and sync points by fallback snapshots, waits only for the claimed chunk, the thread's replay +bounded by the task in flight, writes from a snapshot returned to the mirror and stamped by the world, ticks resolved +in two steps. + +- Interface: none. +- Contract: a tick's report includes its tails' failures, written once after them, and after every earlier tick's; a + run resolves once its tails ran, with their failures; a terminal system on a joined thread that no later system + conflicts with runs after its tick's sync point, unlisted in `warnings`. +- Tests: the plugin marking bodies that never reach `commands`, and not those that may; on one thread through a + `MessageChannel`, tails unwarned, each seeing one tick's values, a run waiting for them, a body reaching `commands` + blocking its tier, a tail that throws in its run's report and its tick's `report`, and tails skipped with a + `"thread"` failure once their thread is gone; in node and chromium against a joined worker, a terminal main system + over 1,000 rows while the world ticks back to back, every run seeing one tick's values, read in place, from a + snapshot when late, and both across a switch at a chunk (red without snapshots: 9,640 rows of another tick's values); + a prompt main never snapshots; a sync point spawning 20,000 rows into what a late tail reads, its labels written from + the snapshot kept and stamped (red without stamps). +- Must not regress: `run idle`, `move main`, `main thread`, `loop tick idle`. +- New: `tail prompt` (ticks of a world system and a 1,000-row tail on a joined worker that keeps up, N = 1,000 ticks), + 18.4 µs a tick in node; `tail late snapshot 10K` (a run writing what a late tail of 10,000 rows of a vec3 and a mat4 + reads, the copy's cost), 6.7 ns a row in node; `tail overlap` and `tail overlap waiting` (ticks writing what the last + tick's 2.5 ms tail of 10,000 rows reads, then working 2 ms, with snapshots and with the writer waiting for the tail), + 2.53 and 4.70 ms a tick in node. + +### Built: phase 7, transfers + +Transferable values in runs' input move to the threads their systems run on (`transfers.ts`): straight to the joined +thread filling it, through channels the world gives pairs of joined threads, the run held until that thread says it has +the input; to the world's thread in the input's message; or kept where they are. A run whose input moved to a thread +that's gone rejects; an input no task took is released once its run finishes; a value whose add drops is released on +its thread. The sample (`src/samples/transfers`) runs the world on a worker drawing into the OffscreenCanvas main moved +to it, main showing its state in the DOM, and a loader worker moving each texture it decoded to the world's thread. + +- Interface: none. An input schema can't name a Transferable type, so a run's input can't be typed with one; proposed + for review, and meanwhile a field `{ allOf: [] }` takes one as `unknown`. +- Contract: a run's Transferable input moves, never copied, to the thread its system runs on, and the run lands once + it arrived, in call order with that thread's other runs; a run whose input moved to a thread that's gone rejects; a + value whose command drops is released on its thread. +- Tests: on one thread through `MessageChannel`s, buffers moved to a joined thread's system in call order with that + thread's other runs, a `MessagePort`, which can't be copied, working there (red without the direct path), to the + world's thread, and kept by the thread running its own system; a run whose input moved to a thread that left + rejecting, later ones going to the world's thread; dropped values released there and here (red without each + release), and what moved for a run that never ran (red without the release). In node and chromium against joined + workers, ten 1 MB buffers and a port moved in order; a run held while its filler's event loop is blocked until the + test releases it, the world's own run finishing meanwhile (red without the hold: the world's run waits for the + filler); dropped ports, and in chromium bitmaps, released there and here. +- Must not regress: `up depth 10 gpu`, `up depth 1000 gpu`. +- New, in chromium: `transfer` (a 1 MB ArrayBuffer and a 1024 by 1024 ImageBitmap, loader to its thread, N = 100), + 134 µs a transfer. + +### Built: indexes + +Indexes on pool workers and joined threads: kernels' lookups through them in WASM and JS on workers, systems on joined +threads looking up through indexes of struct keys, and hash indexes built again across the pool. + +- Interface: none beyond the indexes' own (`design.md`, "Indexes"). +- Contract: a system on a joined thread may look up through an index whose key parts are structs; one with an object + key part, `order` and `up` keep a system on the world's thread. +- Tests: kernels across 0, 1 and 3 workers, WASM and JS, over hash, sorted and unique lookups and `changed`, through + key moves, despawns and spawns (JS red with parameters not refreshed); systems on a joined thread against the same + systems in a world running them itself (red without refreshing records each task, and red fusing a tier that writes + a key part with the tier reading it); records and the index released on removal (red without freeing records); the + same index built across 0 to 3 workers and the same as rows grouped by hand, unique conflicts included, in node and + chromium (red with entity order broken within a partition). +- New: `index rebuild 1M workers` 6.7 ms against 27 on the world's thread alone, `grid neighbors 100K workers` 179 ns a + row. + +### Built: observer systems on joined threads + +A joined thread's `add` and `remove` take observer systems, its `run` one of its own, which run there after the world's +sync points, posted into blocks of their own, and observer systems placed on the joined thread filling their objects run +there the same way (`joined-observer.ts`, `thread-observer.ts`, `thread-directory.ts`). + +- Interface: the documentation of `World.add` and `World.remove`; no types. +- Contract: on a joined thread `add` and `remove` take only observer systems, whose keys need ids, and `run` runs one of + its own there; each runs after the world's sync points, once for the newest when the thread fell behind, reading + structs and the objects of the threads it fills as of one sync point; its failures are in a later run's report, a + run's in that run's; an observer system naming objects of a thread a joined thread claims is placed there, no longer + refused. +- Tests: on one thread through a `MessageChannel`, resources and rows with change filters, composites, lookups by entity + with `changed`, keyless and keyed index lookups, runs with their failures, added ones' failures in a later report, + removal freeing what both threads held, refusals of other systems, placement by objects with world runs, and a thread + that's gone; in node and chromium against a joined worker, 1,000 rows read and looked up while the world ticks back + to back writing them, every run of one tick's values (red without copying: 4,398 rows of another tick's; red without + the lookups' waits: 42,000; red with the sync point not waiting: 69); then, with the world's frames gated, a run a + writer is ready for partway reading the rest from its copy, and a worker holding its event loop over three ticks + running once, for the newest, so neither order depends on the machine's timing; in chromium, a joined worker's + lookups through a sorted index on what a GPU kernel wrote, posted again after a warming run's sync point (red with + posting refreshing before reading back: a count behind). +- New: `joined observer resource`, `joined observer 10K`. +- Then: one reading only struct resources posts copies of them, into two blocks, so neither writers, sync points nor + posts wait for it (`observer-contained.test.ts`, and in node and chromium a worker holding a run while the world runs + on, red waiting for it, and red running again for the value it held without taking a running post's outcome ahead; + the worker runs a system of its own after its add, through the same port, so the world placed the watcher by that + run's sync point and no order depends on the machine's timing). + +## Not built + +- **Several loops per world,** such as fixed-step physics beside rendering. +- **Overlapping ticks by access sets:** starting tick N+1's tiers that don't conflict with tick N's, beyond tails. Ticks + with independent halves would justify it. +- **Per-component write epochs** for read-only readers outside the schedule of large struct data; they shrink the + conflict window without closing it. +- **Ordered systems off the world's thread:** commands from a worker's claimed pieces come back as one segment, so a + piece order across workers needs a segment per piece; and indexes with object parts on the joined thread that holds + their objects. + +## Not planned + +- **A GPU thread:** the device lives with the world, so GPU tiers and readbacks never cross threads. +- **Copying objects between threads,** or snapshots of them: objects stay on their thread. +- **A seqlock over whole runs for readers on joined threads:** back-to-back ticks starve readers, and every reader + thread would evaluate queries again. +- **Double-buffered columns:** twice the memory and a copy of every written column per tick, or front and back swaps per + tier. +- **Snapshots on every tail:** a prompt main would copy every tick for nothing. +- **Writers waiting for tails instead of snapshots, or choosing between the two by a tail's progress:** measured against + snapshots, no better anywhere, and 1.2 to 1.5× slower a tick at a cadence where they overlap, 1.9× back to back + (perf.md, "Tried and not kept"). +- **Objects by entity index,** dense or as a sparse set: memory by index space, or a load per row. +- **A shared heap for asset payloads:** `WebAssembly.Memory` never shrinks, `TextDecoder` rejects shared views, and an + `ImageBitmap` isn't bytes. +- **Several world threads, or moving one:** merges stop being deterministic, and plan and finalizer state would have to + migrate. +- **Inferring threads from values:** it fails late, and a worker can't test for DOM types. +- **Remoting through a JS Proxy.** +- **Liveness polling of joined threads:** a quiet world does no work, so death is what the platform reports, a joined + thread's Web Lock included. diff --git a/packages/ecs/tsconfig.json b/packages/ecs/tsconfig.json new file mode 100644 index 000000000..a2e077b3a --- /dev/null +++ b/packages/ecs/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "../data/tsconfig-base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true, + "types": [ + "@webgpu/types" + ], + "paths": { + "@adobe/ecs": [ + "./src/index.ts" + ] + }, + "lib": [ + "ES2024", + "ESNext.Disposable", + "DOM", + "DOM.Iterable" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "src/interface", + "src/observe" + ], + "references": [ + { + "path": "./src/interface" + }, + { + "path": "./src/observe" + } + ] +} diff --git a/packages/ecs/up-order.md b/packages/ecs/up-order.md new file mode 100644 index 000000000..d9c77180d --- /dev/null +++ b/packages/ecs/up-order.md @@ -0,0 +1,50 @@ +# Keeping the `up` order current + +How `up` keeps each system's tree and order current across sync points, patching what changed rather than rebuilding. Referenced from `design.md` (Phase 2+, In place). + +## Cost + +| Common operation | Cost | +|---|---| +| Few values change, no structural change (filtered run) | O(chunks checked + changed) | +| Few values change, plus spawns or despawns that frame | O(changed + journaled), plus block moves within groups | +| Every row recomputed each frame | kernel-bound | +| Spawn k children | O(k + depth) | +| Despawn k leaves, in any archetype | O(k), plus a block move within a group per removal | +| Reparent a subtree | O(depth + subtree), plus block moves within groups | +| Load a big tree in one batch | O(rows), building the order in full | +| A small tree in a big world | O(changes to the tree); a full build clears arrays sized by every entity index | + +Rows stay in storage order within each group, so the contract ("each in storage order", deterministic commands and reductions) holds whether the order was patched or built in full. A property test applies random spawns, despawns, reparents, matching changes and cycles, and checks full and filtered runs against values computed from reads. + +## Mechanisms + +- **The journal** (`journal.ts`): sync points append the entity index of each plan entry that despawns or changes archetype, each row `swapRemove` moved, each holder written a reference, and each born row, while some `up` system reads it. Each system keeps its own position. The journal keeps at most the entity count of entries, at least 4096; past that it drops them, and a system behind builds again from every row, which costs about what reading them would. +- **The tree, per `up` system** (`up.ts`), by entity index: each matched entity's parent, its level (depth + 1, 0 when unmatched, -1 on or under a parent cycle), and its children as linked lists, linked and unlinked in O(1). + - `update` reads the journal once per entity index: an entity that stops matching is unlinked and its children become roots; one that starts matching, changes parent or moves to another archetype is settled again. + - `settle` walks each pending entity up to a root or to an entity already settled in that call, so every level it sets is exact and a walk that meets itself finds a cycle; then it descends to children whose level changed. Cycles break as well as form, since a journaled entity is walked again. + - `archetypeAt` keeps each entity's archetype as the tree last saw it, so a row that only swap-moved isn't settled again. +- **The order, as segments of one buffer** (`order.ts`): groups of one depth, archetype and parent archetype, run in that order, each a start, length and capacity in one arena buffer of rows, parent rows and entity indexes, with spare slots: an eighth of its rows, at least 8. + - A row is inserted and removed within its group by binary search on its row and a block move, so storage order holds; a spawn's row is the highest in its archetype, so it appends. + - `patch` in `up.ts` runs in two phases over the entities whose slot may have changed: every removal first, so no two slots of a group share a row, then every insertion, or a parent row set in place for a slot that stays. + - A full group moves to the end of the buffer with twice its slots; the buffer compacts, in run order and dropping empty groups, once unused slots outnumber the rows. + - `load` lays out a full build: depths from the tree, a counting sort by depth that keeps storage order, and rows grouped by parent archetype. +- **Full builds:** the tree when the journal dropped entries a system hadn't read; the order then too, and when more than a quarter of its rows, at least 1024, would move. +- **Filtered runs** mark entities into buckets by depth: rows passing their own filters or with a newer `through` tick, from chunk ticks; children of parents whose read values changed; and the journal's entities whose parent, matching or archetype changed, with the children of those that stopped matching or moved archetype. Each listed row marks its children. A depth with few marked rows is sorted by archetype, parent archetype and row; one with more than an eighth of the matched rows, at least 4096, is listed by scanning its groups in order. After a full build of the tree every row runs once. +- **Runs:** a full run reads every group of a depth; the GPU uploads the slot buffer, spare slots included, when the order changes, and dispatches each group from its start. + +## Memory + +- **Per `up` system:** about 40 bytes per entity index for the tree and the order's group and row by entity, 12 bytes per slot, spare slots included, and scratch for a full build per matched row, kept for the next. +- **Journal:** 4 bytes per entry, up to the entity count. + +## Not built + +- **One child list per `through`, shared by every `up` system through it:** each system keeps its own tree, since levels depend on what it matches. Several `up` systems through one component in a big world would justify sharing the child lists. +- **Parent rows gathered once per run:** a swap-moved parent patches the parent row of each of its children. Wide fan-out under parents that swap-move often would justify a gather per group. +- **Uploading only patched groups to the GPU:** the whole slot buffer uploads when the order changes. Large GPU trees with small structural changes each frame would justify it. + +## Not planned + +- **Rows drifting out of storage order:** it would remove the block moves but change the contract, and the block moves are cheap. +- **Depth in the archetype,** as Flecs's cascade does: it splits tables, and archetype ids are positional. diff --git a/packages/ecs/vite.perf.config.ts b/packages/ecs/vite.perf.config.ts new file mode 100644 index 000000000..b374a91ab --- /dev/null +++ b/packages/ecs/vite.perf.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vite"; +import { kernelPlugin } from "./src/implementation/compiler/index.ts"; + +/** + * Bundles the benchmarks for plain node, with kernels compiled into their own chunks as in a production build, and + * `worker.ts` as a second entry, `worker.js`, in the same directory, where the pool's workers load it, and `joined.ts`, + * the thread the joining benchmarks join their worlds from. + */ +export default defineConfig({ + plugins: [kernelPlugin()], + build: { + ssr: true, + outDir: "dist/perf", + emptyOutDir: true, + target: "esnext", + minify: false, + rolldownOptions: { + input: { run: "src/perftest/run.ts", worker: "src/implementation/world/worker.ts", joined: "src/perftest/joined.ts" }, + output: { entryFileNames: "[name].js", chunkFileNames: "[name]-[hash].js" }, + }, + }, + ssr: { noExternal: true }, +}); diff --git a/packages/ecs/vite.sample.config.ts b/packages/ecs/vite.sample.config.ts new file mode 100644 index 000000000..5c9ab8791 --- /dev/null +++ b/packages/ecs/vite.sample.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { isolation, sources } from "./vitest.plugins.ts"; + +/** Serves `src/samples`, one folder per sample, cross-origin isolated for shared memory: `pnpm sample` opens /particles/. */ +export default defineConfig({ + root: "src/samples", + plugins: [kernelPlugin(), isolation, sources], + server: { open: "/particles/" }, +}); diff --git a/packages/ecs/vitest.config.ts b/packages/ecs/vitest.config.ts new file mode 100644 index 000000000..0eff7606e --- /dev/null +++ b/packages/ecs/vitest.config.ts @@ -0,0 +1,69 @@ +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; +import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { isolation, sources } from "./vitest.plugins.ts"; + +/** + * Node tests of real threads, which import fixtures the global setup builds (`vitest.fixtures.ts`), natively, so their + * workers load the built `worker.js`. + */ +const threads = "src/implementation/world/threads.test.ts"; + +/** A test's timeout only guards against a hang, which no test asserts on: long enough for a machine busy elsewhere. */ +const timeouts = { testTimeout: 30000, hookTimeout: 30000 }; + +/** + * Node exposes its collector, and what finishes optimizing compiles in flight, which hold what they read, so tests of + * what a world holds can collect what nothing references (`lifetime.test-helper.ts`). + */ +const execArgv = ["--expose-gc", "--allow-natives-syntax"]; + +// Three groups, one after another, so tests of real threads never share the machine with other tests: `*.test.ts` in +// node, files in parallel; then the node tests of real threads; then `*.browser.test.ts` in chromium, with real +// workers, a file at a time. +export default defineConfig({ + test: { + projects: [ + { + plugins: [kernelPlugin()], + test: { + ...timeouts, + execArgv, + name: "node", + include: ["src/**/*.test.ts"], + exclude: ["src/**/*.browser.test.ts", threads, "**/node_modules/**"], + sequence: { groupOrder: 0 }, + }, + }, + { + plugins: [kernelPlugin()], + test: { + ...timeouts, + execArgv, + name: "threads", + include: [threads], + globalSetup: ["./vitest.fixtures.ts"], + server: { deps: { external: [/\/dist\/fixtures\//] } }, + sequence: { groupOrder: 1 }, + }, + }, + { + plugins: [kernelPlugin(), isolation, sources], + test: { + ...timeouts, + name: "browser", + include: ["src/**/*.browser.test.ts"], + fileParallelism: false, + sequence: { groupOrder: 2 }, + browser: { + enabled: true, + // Full Chromium rather than the headless shell, whose WebGPU computes trigonometry less precisely. + provider: playwright({ launchOptions: { channel: "chromium", args: ["--enable-unsafe-webgpu"] } }), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, + }, + ], + }, +}); diff --git a/packages/ecs/vitest.fixtures.ts b/packages/ecs/vitest.fixtures.ts new file mode 100644 index 000000000..5ea2af654 --- /dev/null +++ b/packages/ecs/vitest.fixtures.ts @@ -0,0 +1,68 @@ +import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; +import type { TestProject } from "vitest/node"; +import { kernelPlugin } from "./src/implementation/compiler/index.ts"; + +const root = dirname(fileURLToPath(import.meta.url)); +/** Where each test run builds its fixtures, in a directory of its own: `run--`. */ +const fixturesRoot = join(root, "dist", "fixtures"); + +/** Whether the process that built a run's fixtures is still running. */ +const running = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } + catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +/** Removes what runs that ended left, as one stopped mid-way does, keeping the directories of runs still going. */ +const removeAbandoned = (): void => { + for (const entry of readdirSync(fixturesRoot, { withFileTypes: true })) { + const pid = /^run-(\d+)-/.exec(entry.name)?.[1]; + if (entry.isDirectory() && pid !== undefined && running(Number(pid))) continue; + rmSync(join(fixturesRoot, entry.name), { recursive: true, force: true }); + } +}; + +const fixtureSources = (directory: string): string[] => readdirSync(directory, { withFileTypes: true }).flatMap(entry => + entry.isDirectory() ? fixtureSources(join(directory, entry.name)) : entry.name.endsWith(".fixture.ts") ? [join(directory, entry.name)] : []); + +/** + * Vitest's global setup for node tests that use real threads: bundles every `*.fixture.ts` with the plugin, as + * `pnpm perf` bundles benchmarks, and `worker.ts` as a second entry, `worker.js`, in one directory, since a + * `worker_threads` worker doesn't go through Vitest's module runner. A built fixture has its own registry, so the + * keys and systems of its cases live in it. Each run builds into a directory of its own, which it removes when it + * ends, so runs at the same time in one checkout never replace each other's fixtures. + */ +export default async (project: TestProject): Promise<() => void> => { + mkdirSync(fixturesRoot, { recursive: true }); + removeAbandoned(); + const fixturesDirectory = mkdtempSync(join(fixturesRoot, `run-${process.pid}-`)); + const fixtures = fixtureSources(join(root, "src")); + const input = Object.fromEntries(fixtures.map(file => [basename(file, ".ts"), relative(root, file)])); + await build({ + configFile: false, + root, + logLevel: "warn", + plugins: [kernelPlugin()], + build: { + ssr: true, + outDir: fixturesDirectory, + emptyOutDir: true, + target: "esnext", + minify: false, + rolldownOptions: { + input: { ...input, worker: resolve(root, "src/implementation/world/worker.ts") }, + output: { entryFileNames: "[name].js", chunkFileNames: "[name]-[hash].js" }, + }, + }, + ssr: { noExternal: true }, + }); + project.provide("fixturesDirectory", fixturesDirectory); + return () => rmSync(fixturesDirectory, { recursive: true, force: true }); +}; diff --git a/packages/ecs/vitest.perf.config.ts b/packages/ecs/vitest.perf.config.ts new file mode 100644 index 000000000..39af95d44 --- /dev/null +++ b/packages/ecs/vitest.perf.config.ts @@ -0,0 +1,30 @@ +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig } from "vitest/config"; +import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { isolation, sources } from "./vitest.plugins.ts"; + +// `*.perf-test.ts` runs in node; `*.perf-browser-test.ts` runs in chromium, with real workers. +export default defineConfig({ + test: { + // `pnpm perf:browser` reads the results from the console lines, which the default reporter drops for passing tests. + reporters: "verbose", + projects: [ + { plugins: [kernelPlugin()], test: { name: "perf-node", include: ["src/**/*.perf-test.ts"] } }, + { + plugins: [kernelPlugin(), isolation, sources], + test: { + name: "perf-browser", + include: ["src/**/*.perf-browser-test.ts"], + testTimeout: 120_000, + browser: { + enabled: true, + // Full Chromium rather than the headless shell, whose WebGPU computes trigonometry less precisely. + provider: playwright({ launchOptions: { channel: "chromium", args: ["--enable-unsafe-webgpu"] } }), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, + }, + ], + }, +}); diff --git a/packages/ecs/vitest.plugins.ts b/packages/ecs/vitest.plugins.ts new file mode 100644 index 000000000..e06d39fc8 --- /dev/null +++ b/packages/ecs/vitest.plugins.ts @@ -0,0 +1,26 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Plugin } from "vite"; + +const root = dirname(fileURLToPath(import.meta.url)); + +/** Shared memory needs cross-origin isolation, on every response the browser test server sends, its own pages included. */ +export const isolation: Plugin = { + name: "cross-origin-isolation", + enforce: "pre", + config: () => ({ server: { headers: { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp" } } }), +}; + +/** Sources import siblings as `.js`; serve the `.ts` when only it exists, as `tsc` output would provide the `.js`. */ +export const sources: Plugin = { + name: "typescript-sources", + enforce: "pre", + resolveId: source => { + const [path, query] = source.split("?"); + if (!path.endsWith(".js") || !path.includes("/src/") || existsSync(path)) return null; + const typescript = path.replace(/\.js$/, ".ts"); + const absolute = existsSync(typescript) ? typescript : resolve(root, `.${typescript}`); + return existsSync(absolute) ? `${absolute}${query === undefined ? "" : `?${query}`}` : null; + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de2844774..530538f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.21.0 version: 6.21.0(eslint@8.56.0)(typescript@5.8.3) - '@vitest/browser': - specifier: ^1.6.0 - version: 1.6.0(playwright@1.46.1)(vitest@1.6.0)(webdriverio@9.0.9) + '@vitest/browser-playwright': + specifier: ^5.0.5 + version: 5.0.5(playwright@1.64.1)(vite@8.3.4)(vitest@5.0.5) '@webgpu/types': specifier: ^0.1.61 version: 0.1.61 @@ -94,8 +94,8 @@ importers: specifier: ^4.1.5 version: 4.1.5 playwright: - specifier: ^1.46.1 - version: 1.46.1 + specifier: ^1.64.1 + version: 1.64.1 prettier: specifier: ^3.2.5 version: 3.2.5 @@ -107,7 +107,7 @@ importers: version: 4.0.0(prettier@3.2.5) riteway: specifier: v8.0.0-RC4 - version: 8.0.0-RC4(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0)(react@18.3.1) + version: 8.0.0-RC4(@types/node@25.6.0)(jsdom@24.1.0)(react@18.3.1) signal-polyfill: specifier: ^0.2.2 version: 0.2.2 @@ -127,11 +127,11 @@ importers: specifier: ^10.0.0 version: 10.0.0 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) webdriverio: specifier: ^9.0.9 version: 9.0.9 @@ -145,8 +145,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-gpu: dependencies: @@ -167,8 +167,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-gpu-hopper: dependencies: @@ -198,14 +198,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-gpu-samples: dependencies: @@ -229,11 +229,11 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) packages/data-lit: dependencies: @@ -251,8 +251,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(happy-dom@20.14.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(happy-dom@20.14.0)(vite@8.3.4) packages/data-lit-space-rock-game: dependencies: @@ -276,14 +276,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-lit-tictactoe: dependencies: @@ -307,14 +307,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-lit-todo: dependencies: @@ -362,14 +362,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-p2p-tictactoe: dependencies: @@ -396,14 +396,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-persistence: dependencies: @@ -411,21 +411,21 @@ importers: specifier: workspace:* version: link:../data devDependencies: - '@vitest/browser': - specifier: ^1.6.0 - version: 1.6.0(playwright@1.46.1)(vitest@1.6.0) + '@vitest/browser-playwright': + specifier: ^5.0.5 + version: 5.0.5(playwright@1.64.1)(vite@8.3.4)(vitest@5.0.5) playwright: - specifier: ^1.46.1 - version: 1.46.1 + specifier: ^1.64.1 + version: 1.64.1 typescript: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-react: dependencies: @@ -443,8 +443,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-react-hello: dependencies: @@ -468,17 +468,17 @@ importers: specifier: ^19 version: 19.0.0 '@vitejs/plugin-react': - specifier: ^4.3.0 - version: 4.3.0(vite@5.1.1) + specifier: ^6.1.2 + version: 6.1.2(vite@8.3.4) typescript: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) packages/data-react-pixie: dependencies: @@ -511,8 +511,8 @@ importers: specifier: ^19 version: 19.0.0 '@vitejs/plugin-react': - specifier: ^4.3.0 - version: 4.3.0(vite@5.1.1) + specifier: ^6.1.2 + version: 6.1.2(vite@8.3.4) jsdom: specifier: ^24.1.0 version: 24.1.0 @@ -520,14 +520,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-rpc: dependencies: @@ -542,8 +542,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-rpc-iframe: dependencies: @@ -567,14 +567,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-solid: dependencies: @@ -589,8 +589,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-solid-dashboard: dependencies: @@ -614,17 +614,17 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^5.1.1 - version: 5.1.1(@types/node@25.6.0) + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) vite-plugin-checker: - specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(vite@5.1.1) + specifier: ^0.14.5 + version: 0.14.5(typescript@5.8.3)(vite@8.3.4) vite-plugin-solid: - specifier: ^2.11.0 - version: 2.11.0(solid-js@1.9.12)(vite@5.1.1) + specifier: ^2.11.14 + version: 2.11.14(solid-js@1.9.12)(vite@8.3.4) vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-sync: dependencies: @@ -636,8 +636,8 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages/data-testing: dependencies: @@ -649,8 +649,35 @@ importers: specifier: ^5.8.3 version: 5.8.3 vitest: - specifier: ^1.6.0 - version: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) + + packages/ecs: + devDependencies: + '@types/estree': + specifier: ^1.0.5 + version: 1.0.9 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@vitest/browser-playwright': + specifier: ^5.0.5 + version: 5.0.5(playwright@1.64.1)(vite@8.3.4)(vitest@5.0.5) + '@webgpu/types': + specifier: ^0.1.61 + version: 0.1.61 + playwright: + specifier: ^1.64.1 + version: 1.64.1 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@25.6.0) + vitest: + specifier: ^5.0.5 + version: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) packages: @@ -847,26 +874,6 @@ packages: '@babel/helper-plugin-utils': 7.29.7 dev: true - /@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7): - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - dev: true - - /@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7): - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - dev: true - /@babel/template@7.29.7: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -925,6 +932,10 @@ packages: '@babel/helper-validator-identifier': 7.29.7 dev: true + /@blazediff/core@1.10.0: + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + dev: true + /@cfworker/json-schema@4.1.1: resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} dev: false @@ -1329,7 +1340,7 @@ packages: /@jridgewell/gen-mapping@0.3.13: resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 dev: true @@ -1345,15 +1356,15 @@ packages: engines: {node: '>=6.0.0'} dev: true - /@jridgewell/sourcemap-codec@1.5.5: - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + /@jridgewell/sourcemap-codec@1.6.0: + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} dev: true /@jridgewell/trace-mapping@0.3.31: resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 dev: true /@lit-labs/observers@2.0.2: @@ -1424,6 +1435,10 @@ packages: fastq: 1.20.1 dev: true + /@oxc-project/types@0.154.0: + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + dev: true + /@pixi/colord@2.9.6: resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} dev: false @@ -1483,6 +1498,145 @@ packages: - supports-color dev: true + /@rolldown/binding-android-arm-eabi@1.2.14: + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-android-arm64@1.2.14: + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-darwin-arm64@1.2.14: + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-darwin-x64@1.2.14: + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-freebsd-x64@1.2.14: + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-linux-arm-gnueabihf@1.2.14: + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-linux-arm64-gnu@1.2.14: + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-linux-arm64-musl@1.2.14: + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-linux-ppc64-gnu@1.2.14: + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-linux-s390x-gnu@1.2.14: + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-linux-x64-gnu@1.2.14: + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-linux-x64-musl@1.2.14: + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-openharmony-arm64@1.2.14: + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-win32-arm64-msvc@1.2.14: + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rolldown/binding-win32-x64-msvc@1.2.14: + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rolldown/pluginutils@1.0.1: + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + dev: true + /@rollup/rollup-android-arm-eabi@4.62.4: resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -1947,10 +2101,21 @@ packages: '@babel/types': 7.29.8 dev: true + /@types/chai@5.2.3: + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + dev: true + /@types/css-font-loading-module@0.0.12: resolution: {integrity: sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==} dev: false + /@types/deep-eql@4.0.2: + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + dev: true + /@types/earcut@2.1.4: resolution: {integrity: sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==} dev: false @@ -2200,65 +2365,64 @@ packages: resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} dev: true - /@vitejs/plugin-react@4.3.0(vite@5.1.1): - resolution: {integrity: sha512-KcEbMsn4Dpk+LIbHMj7gDPRKaTMStxxWRkRmxsg/jVdFdJCZWt1SchZcf0M4t8lIKdwwMsEyzhrcOXRrDPtOBw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.1.1(@types/node@25.6.0) - transitivePeerDependencies: - - supports-color - dev: true - - /@vitest/browser@1.6.0(playwright@1.46.1)(vitest@1.6.0): - resolution: {integrity: sha512-3Wpp9h1hf++rRVPvoXevkdHybLhJVn7MwIMKMIh08tVaoDMmT6fnNhbP222Z48V9PptpYeA5zvH9Ct/ZcaAzmQ==} + /@vitejs/plugin-react@6.1.2(vite@8.3.4): + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - playwright: '*' - safaridriver: '*' - vitest: 1.6.0 - webdriverio: '*' + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 peerDependenciesMeta: - playwright: + '@rolldown/plugin-babel': optional: true - safaridriver: + babel-plugin-react-compiler: optional: true - webdriverio: + oxc-transform-react: optional: true dependencies: - '@vitest/utils': 1.6.0 - magic-string: 0.30.21 - playwright: 1.46.1 - sirv: 2.0.4 - vitest: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0) + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@25.6.0) dev: true - /@vitest/browser@1.6.0(playwright@1.46.1)(vitest@1.6.0)(webdriverio@9.0.9): - resolution: {integrity: sha512-3Wpp9h1hf++rRVPvoXevkdHybLhJVn7MwIMKMIh08tVaoDMmT6fnNhbP222Z48V9PptpYeA5zvH9Ct/ZcaAzmQ==} + /@vitest/browser-playwright@5.0.5(playwright@1.64.1)(vite@8.3.4)(vitest@5.0.5): + resolution: {integrity: sha512-lcdThFUUcMV9mGGV1SUiZXPxfjgW4CssA7o3STxzh8WK0tT2r526kc12tZIBRZfoLMwkhxjLmMXkWXONzdsDQw==} peerDependencies: playwright: '*' - safaridriver: '*' - vitest: 1.6.0 - webdriverio: '*' - peerDependenciesMeta: - playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true + vitest: 5.0.5 dependencies: - '@vitest/utils': 1.6.0 - magic-string: 0.30.21 - playwright: 1.46.1 - sirv: 2.0.4 - vitest: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) - webdriverio: 9.0.9 + '@vitest/browser': 5.0.5(vite@8.3.4)(vitest@5.0.5) + '@vitest/mocker': 5.0.5(vite@8.3.4) + playwright: 1.64.1 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + dev: true + + /@vitest/browser@5.0.5(vite@8.3.4)(vitest@5.0.5): + resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==} + peerDependencies: + vitest: 5.0.5 + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.5(vite@8.3.4) + '@vitest/ui': 5.0.5(vitest@5.0.5) + '@vitest/utils': 5.0.5 + magic-string: 1.4.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite dev: true /@vitest/expect@1.6.0: @@ -2269,6 +2433,30 @@ packages: chai: 4.5.0 dev: true + /@vitest/mocker@5.0.5(vite@8.3.4): + resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.5 + estree-walker: 3.0.3 + magic-string: 1.4.3 + vite: 8.3.4(@types/node@25.6.0) + dev: true + + /@vitest/pretty-format@5.0.5: + resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==} + dependencies: + tinyrainbow: 3.2.0 + dev: true + /@vitest/runner@1.6.0: resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} dependencies: @@ -2291,6 +2479,24 @@ packages: tinyspy: 2.2.1 dev: true + /@vitest/spy@5.0.5: + resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + dev: true + + /@vitest/ui@5.0.5(vitest@5.0.5): + resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==} + peerDependencies: + vitest: 5.0.5 + dependencies: + '@vitest/utils': 5.0.5 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4) + dev: true + /@vitest/utils@1.6.0: resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} dependencies: @@ -2300,6 +2506,14 @@ packages: pretty-format: 29.7.0 dev: true + /@vitest/utils@5.0.5: + resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} + dependencies: + '@vitest/pretty-format': 5.0.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + dev: true + /@wdio/config@9.0.8: resolution: {integrity: sha512-37L+hd+A1Nyehd/pgfTrLC6w+Ngbu0CIoFh9Vv6v8Cgu5Hih0TLofvlg+J1BNbcTd5eQ2tFKZBDeFMhQaIiTpg==} engines: {node: '>=18.20.0'} @@ -2637,6 +2851,11 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + dev: true + /ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} @@ -2915,6 +3134,11 @@ packages: type-detect: 4.1.0 dev: true + /chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + dev: true + /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3004,11 +3228,11 @@ packages: fsevents: 2.3.3 dev: true - /chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} + /chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} dependencies: - readdirp: 4.1.2 + readdirp: 5.1.1 dev: true /clean-stack@2.2.0: @@ -3219,7 +3443,7 @@ packages: engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} dependencies: mdn-data: 2.0.30 - source-map-js: 1.2.1 + source-map-js: 1.2.2 dev: true /css-value@0.0.1: @@ -3401,6 +3625,11 @@ packages: engines: {node: '>=0.4.0'} dev: true + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dev: true + /diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3645,6 +3874,10 @@ packages: stop-iteration-iterator: 1.1.0 dev: true + /es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + dev: true + /es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -4110,6 +4343,11 @@ packages: strip-final-newline: 3.0.0 dev: true + /expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + dev: true + /extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -4186,7 +4424,7 @@ packages: pend: 1.2.0 dev: true - /fdir@6.5.0(picomatch@4.0.5): + /fdir@6.5.0(picomatch@4.0.7): resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: @@ -4195,7 +4433,7 @@ packages: picomatch: optional: true dependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 dev: true /fetch-blob@3.2.0: @@ -4206,6 +4444,10 @@ packages: web-streams-polyfill: 3.3.3 dev: true + /fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -4316,14 +4558,6 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true - optional: true - /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5284,6 +5518,124 @@ packages: immediate: 3.0.6 dev: true + /lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + dev: true + /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -5479,7 +5831,13 @@ packages: /magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 + dev: true + + /magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 dev: true /markdown-it@14.3.0: @@ -5644,8 +6002,8 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true - /nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + /nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true dev: true @@ -5841,6 +6199,11 @@ packages: es-object-atoms: 1.1.2 dev: true + /obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + dev: true + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -6085,8 +6448,8 @@ packages: engines: {node: '>=10'} dev: true - /picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + /picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} dev: true @@ -6129,20 +6492,18 @@ packages: pathe: 2.0.3 dev: true - /playwright-core@1.46.1: - resolution: {integrity: sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==} - engines: {node: '>=18'} + /playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} hasBin: true dev: true - /playwright@1.46.1: - resolution: {integrity: sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==} - engines: {node: '>=18'} + /playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} hasBin: true dependencies: - playwright-core: 1.46.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.1 dev: true /plur@4.0.0: @@ -6152,6 +6513,11 @@ packages: irregular-plurals: 3.5.0 dev: true + /pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + dev: true + /pnpm@9.0.0: resolution: {integrity: sha512-tBBnB8ciWxdIthWVlTzL6/+XtUrQXQAqo2NfYzucU81mb3zpuLxEcE8foEi5pJtVNxqy2enWZ9Hv4u8VFLzVEw==} engines: {node: '>=18.12'} @@ -6192,9 +6558,18 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.18 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 + dev: true + + /postcss@8.5.30: + resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 dev: true /prelude-ls@1.1.2: @@ -6275,6 +6650,14 @@ packages: engines: {node: '>=0.4.0'} dev: true + /proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + dev: true + /proxy-agent@6.5.0: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} @@ -6367,11 +6750,6 @@ packages: scheduler: 0.25.0 dev: false - /react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} - engines: {node: '>=0.10.0'} - dev: true - /react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -6428,9 +6806,9 @@ packages: picomatch: 2.3.2 dev: true - /readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} + /readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} dev: true /reflect.getprototypeof@1.0.10: @@ -6521,6 +6899,11 @@ packages: signal-exit: 3.0.7 dev: true + /retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + dev: true + /reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -6542,7 +6925,7 @@ packages: glob: 7.2.3 dev: true - /riteway@8.0.0-RC4(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0)(react@18.3.1): + /riteway@8.0.0-RC4(@types/node@25.6.0)(jsdom@24.1.0)(react@18.3.1): resolution: {integrity: sha512-zUqwi4jR1iVgVAFLRuBerhHilvGxHjxibW3wHdMh8EaYFAWywmmq0kaF9diibnJ3wLue/TVEqX6NtS/ePj/eGw==} hasBin: true dependencies: @@ -6550,7 +6933,7 @@ packages: esm: 3.2.25 react-dom: 18.2.0(react@18.3.1) tape: 5.10.2 - vitest: 1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0) + vitest: 1.6.0(@types/node@25.6.0)(jsdom@24.1.0) transitivePeerDependencies: - '@edge-runtime/vm' - '@types/node' @@ -6568,6 +6951,31 @@ packages: - terser dev: true + /rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + dependencies: + '@oxc-project/types': 0.154.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 + dev: true + /rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -6848,9 +7256,9 @@ packages: semver: 7.8.5 dev: true - /sirv@2.0.4: - resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} - engines: {node: '>= 10'} + /sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} dependencies: '@polka/url': 1.0.0-next.29 mrmime: 2.0.1 @@ -6940,8 +7348,8 @@ packages: resolution: {integrity: sha512-Q8tOrwB1TSv9fNUXym9st3TZJODtmcOIi2JWCkVNQPrRg17KPwlpwweTEb7pMwUIFMTAgx2/JsQQXEPFzYQj3A==} dev: true - /source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + /source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} dev: true @@ -7001,6 +7409,10 @@ packages: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} dev: true + /std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + dev: true + /stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -7366,12 +7778,22 @@ packages: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} dev: true + /tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + dev: true + + /tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + dev: true + /tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 dev: true /tinypool@0.8.4: @@ -7379,6 +7801,11 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + dev: true + /tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} @@ -7675,20 +8102,18 @@ packages: - terser dev: true - /vite-plugin-checker@0.12.0(typescript@5.8.3)(vite@5.1.1): - resolution: {integrity: sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==} - engines: {node: '>=16.11'} + /vite-plugin-checker@0.14.5(typescript@5.8.3)(vite@8.3.4): + resolution: {integrity: sha512-c9lQ92eisUO+F7Fd93aelojmiOS+NQpPgQ1XR2LTQHox1/laZf4yAoQj+L3RA9Vgh10e2nFd9b8r2LLyYZsbpA==} + engines: {node: '>=20.19.0'} peerDependencies: - '@biomejs/biome': '>=1.7' - eslint: '>=9.39.1' - meow: ^13.2.0 + '@biomejs/biome': '>=2.4.12' + eslint: '>=9.39.4' + meow: ^13.2.0 || ^14.0.0 optionator: ^0.9.4 oxlint: '>=1' - stylelint: '>=16' + stylelint: '>=16.26.1' typescript: '*' vite: '>=5.4.21' - vls: '*' - vti: '*' vue-tsc: ~2.2.10 || ^3.0.0 peerDependenciesMeta: '@biomejs/biome': @@ -7705,31 +8130,26 @@ packages: optional: true typescript: optional: true - vls: - optional: true - vti: - optional: true vue-tsc: optional: true dependencies: '@babel/code-frame': 7.29.7 - chokidar: 4.0.3 + chokidar: 5.0.0 npm-run-path: 6.0.0 picocolors: 1.1.1 - picomatch: 4.0.5 + picomatch: 4.0.7 + proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 - tinyglobby: 0.2.17 typescript: 5.8.3 - vite: 5.1.1(@types/node@25.6.0) - vscode-uri: 3.1.0 + vite: 8.3.4(@types/node@25.6.0) dev: true - /vite-plugin-solid@2.11.0(solid-js@1.9.12)(vite@5.1.1): - resolution: {integrity: sha512-G+NiwDj4EAeUE0wt3Ur9f+Lt9oMUuLd0FIxYuqwJSqRacKQRteCwUFzNy8zMEt88xWokngQhiFjfJMhjc1fDXw==} + /vite-plugin-solid@2.11.14(solid-js@1.9.12)(vite@8.3.4): + resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} peerDependencies: - '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0 solid-js: ^1.7.2 - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 peerDependenciesMeta: '@testing-library/jest-dom': optional: true @@ -7740,8 +8160,8 @@ packages: merge-anything: 5.1.7 solid-js: 1.9.12 solid-refresh: 0.6.3(solid-js@1.9.12) - vite: 5.1.1(@types/node@25.6.0) - vitefu: 1.1.3(vite@5.1.1) + vite: 8.3.4(@types/node@25.6.0) + vitefu: 1.1.3(vite@8.3.4) transitivePeerDependencies: - supports-color dev: true @@ -7776,13 +8196,66 @@ packages: dependencies: '@types/node': 25.6.0 esbuild: 0.19.12 - postcss: 8.5.26 + postcss: 8.5.30 rollup: 4.62.4 optionalDependencies: fsevents: 2.3.3 dev: true - /vitefu@1.1.3(vite@5.1.1): + /vite@8.3.4(@types/node@25.6.0): + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + dependencies: + '@types/node': 25.6.0 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.30 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitefu@1.1.3(vite@8.3.4): resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -7790,10 +8263,10 @@ packages: vite: optional: true dependencies: - vite: 5.1.1(@types/node@25.6.0) + vite: 8.3.4(@types/node@25.6.0) dev: true - /vitest@1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0): + /vitest@1.6.0(@types/node@25.6.0)(jsdom@24.1.0): resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7819,7 +8292,6 @@ packages: optional: true dependencies: '@types/node': 25.6.0 - '@vitest/browser': 1.6.0(playwright@1.46.1)(vitest@1.6.0) '@vitest/expect': 1.6.0 '@vitest/runner': 1.6.0 '@vitest/snapshot': 1.6.0 @@ -7829,6 +8301,7 @@ packages: chai: 4.5.0 debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 + jsdom: 24.1.0 local-pkg: 0.5.1 magic-string: 0.30.21 pathe: 1.1.2 @@ -7850,23 +8323,39 @@ packages: - terser dev: true - /vitest@1.6.0(@types/node@25.6.0)(@vitest/browser@1.6.0)(jsdom@24.1.0): - resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} - engines: {node: ^18.0.0 || >=20.0.0} + /vitest@5.0.5(@types/node@25.6.0)(@vitest/browser-playwright@5.0.5)(jsdom@24.1.0)(vite@8.3.4): + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.0 - '@vitest/ui': 1.6.0 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 happy-dom: '*' jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@opentelemetry/api': + optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -7875,56 +8364,60 @@ packages: jsdom: optional: true dependencies: + '@types/chai': 5.2.3 '@types/node': 25.6.0 - '@vitest/browser': 1.6.0(playwright@1.46.1)(vitest@1.6.0)(webdriverio@9.0.9) - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.5 - chai: 4.5.0 - debug: 4.4.3(supports-color@5.5.0) - execa: 8.0.1 + '@vitest/browser-playwright': 5.0.5(playwright@1.64.1)(vite@8.3.4)(vitest@5.0.5) + '@vitest/mocker': 5.0.5(vite@8.3.4) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 jsdom: 24.1.0 - local-pkg: 0.5.1 - magic-string: 0.30.21 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.10.0 - strip-literal: 2.1.1 - tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.1.1(@types/node@25.6.0) - vite-node: 1.6.0(@types/node@25.6.0) - why-is-node-running: 2.3.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@25.6.0) + why-is-node-running: 3.2.1 transitivePeerDependencies: - - less - - lightningcss - - sass - - stylus - - sugarss - - supports-color - - terser + - msw dev: true - /vitest@1.6.0(@types/node@25.6.0)(happy-dom@20.14.0): - resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} - engines: {node: ^18.0.0 || >=20.0.0} + /vitest@5.0.5(@types/node@25.6.0)(happy-dom@20.14.0)(vite@8.3.4): + resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.0 - '@vitest/ui': 1.6.0 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.5 + '@vitest/browser-preview': 5.0.5 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.5 + '@vitest/coverage-v8': 5.0.5 + '@vitest/ui': 5.0.5 happy-dom: '*' jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true + '@opentelemetry/api': + optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -7933,40 +8426,24 @@ packages: jsdom: optional: true dependencies: + '@types/chai': 5.2.3 '@types/node': 25.6.0 - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.5 - chai: 4.5.0 - debug: 4.4.3(supports-color@5.5.0) - execa: 8.0.1 + '@vitest/mocker': 5.0.5(vite@8.3.4) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 happy-dom: 20.14.0 - local-pkg: 0.5.1 - magic-string: 0.30.21 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.10.0 - strip-literal: 2.1.1 - tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.1.1(@types/node@25.6.0) - vite-node: 1.6.0(@types/node@25.6.0) - why-is-node-running: 2.3.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@25.6.0) + why-is-node-running: 3.2.1 transitivePeerDependencies: - - less - - lightningcss - - sass - - stylus - - sugarss - - supports-color - - terser - dev: true - - /vscode-uri@3.1.0: - resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + - msw dev: true /w3c-xmlserializer@5.0.0: @@ -8005,7 +8482,7 @@ packages: '@wdio/types': 9.0.8 '@wdio/utils': 9.0.8 deepmerge-ts: 7.1.6 - ws: 8.21.3 + ws: 8.22.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -8176,6 +8653,12 @@ packages: stackback: 0.0.2 dev: true + /why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + dev: true + /word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -8232,6 +8715,19 @@ packages: optional: true dev: true + /ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 768f5fbda..dff66926a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - 'packages/data' - 'packages/data-ai' + - 'packages/ecs' - 'packages/data-testing' - 'packages/data-persistence' - 'packages/data-sync' diff --git a/scripts/bump.mjs b/scripts/bump.mjs index 55b1ca671..be14a0a0c 100644 --- a/scripts/bump.mjs +++ b/scripts/bump.mjs @@ -10,6 +10,10 @@ // noise. Publishability is keyed off the same `private` field that governs // `pnpm -r publish`, so there is no package list to maintain here. // +// A publishable package that sets `"independentVersion": true` in its own +// package.json keeps its own version, which its maintainers bump by hand: it +// publishes with the rest but isn't part of the lockstep release. +// // Only the `"version"` line of each file is rewritten, so diffs stay one line // per package instead of a full reformat. @@ -44,6 +48,7 @@ setVersion(rootPath, version); const packagesDir = join(root, "packages"); const bumped = []; const skipped = []; +const independent = []; for (const name of readdirSync(packagesDir)) { const pkgPath = join(packagesDir, name, "package.json"); const pkg = tryReadPkg(pkgPath); @@ -52,6 +57,10 @@ for (const name of readdirSync(packagesDir)) { skipped.push(pkg.name ?? name); continue; } + if (pkg.independentVersion === true) { + independent.push(pkg.name ?? name); + continue; + } setVersion(pkgPath, version); bumped.push(pkg.name ?? name); } @@ -62,4 +71,5 @@ if (tryReadPkg(pluginPath)) setVersion(pluginPath, version); console.log(`v${version}`); console.log(` bumped ${bumped.length}: ${bumped.sort().join(", ")}`); +console.log(` kept ${independent.length} independent: ${independent.sort().join(", ")}`); console.log(` skipped ${skipped.length} private: ${skipped.sort().join(", ")}`);