Compare commits
12
Commits
d095ee7849
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7070799862 | ||
|
|
53a53a8f3f | ||
|
|
0516b9a9ba | ||
|
|
f40314dcdc | ||
|
|
9ca12c237e | ||
|
|
0e6d7e367c | ||
|
|
481d105f19 | ||
|
|
abfa428464 | ||
|
|
000bfd63bf | ||
|
|
ded70a3cb4 | ||
|
|
9768765d74 | ||
|
|
239b1d25b0 |
+1
-1
@@ -2,5 +2,5 @@
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
for command in npm cargo wasm-pack; do command -v "$command" >/dev/null; done
|
||||
for command in npm cargo wasm-pack go; do command -v "$command" >/dev/null; done
|
||||
amp orb services ensure
|
||||
|
||||
@@ -10,5 +10,15 @@ rustup toolchain install "$toolchain" --profile minimal --component rust-src,rus
|
||||
if ! command -v wasm-pack >/dev/null; then
|
||||
cargo install wasm-pack --version 0.15.0 --locked
|
||||
fi
|
||||
if ! command -v go >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y golang-go
|
||||
fi
|
||||
if ! command -v git-lfs >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y git-lfs
|
||||
fi
|
||||
git lfs install --local
|
||||
git lfs pull --include="docs/public/models/sponza.glb"
|
||||
npm ci
|
||||
amp orb services ensure
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
yawn-docs:
|
||||
command: npm start
|
||||
yawn-server:
|
||||
command: npm run server
|
||||
portal:
|
||||
title: Yawn
|
||||
description: Documentation and minimal WebGPU playground.
|
||||
description: Yawn website, tutorial, package server, and WebGPU playground.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
docs/public/models/sponza.glb filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -30,6 +30,7 @@ core/pkg
|
||||
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
server/data/
|
||||
|
||||
# Amp runtime artifacts
|
||||
.amp/in/
|
||||
|
||||
Generated
+567
@@ -2,36 +2,340 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck_derive"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"num-traits",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hexf-parse"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting",
|
||||
"half",
|
||||
"hashbrown 0.15.5",
|
||||
"hexf-parse",
|
||||
"indexmap",
|
||||
"libm",
|
||||
"log",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash",
|
||||
"thiserror",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -41,6 +345,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -50,12 +360,45 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -63,6 +406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -98,6 +442,24 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -120,12 +482,47 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
@@ -139,6 +536,16 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
@@ -171,12 +578,172 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "26.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"document-features",
|
||||
"hashbrown 0.15.5",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"wgpu-core",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core"
|
||||
version = "26.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags",
|
||||
"cfg_aliases",
|
||||
"document-features",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"log",
|
||||
"naga",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "26.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libloading",
|
||||
"log",
|
||||
"naga",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"raw-window-handle",
|
||||
"renderdoc-sys",
|
||||
"thiserror",
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-types"
|
||||
version = "26.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yawn-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
# Yawn
|
||||
|
||||
Yawn Core is two things: a generic structure-of-arrays arena in a `SharedArrayBuffer`, and a render-graph worker that turns externally supplied WGSL into an up-front WebGPU loadout.
|
||||
Yawn Core is two things: a generic structure-of-arrays arena in a `SharedArrayBuffer`, and a Rust render graph that turns externally supplied WGSL into an up-front WebGPU loadout.
|
||||
|
||||
```text
|
||||
JSO or FXNode → AST → S-expression → graph worker → WebGPU
|
||||
JSO or FXNode → AST → S-expression → worker messages → Rust/WebGPU
|
||||
↑
|
||||
any thread → direct shared row writes ┘
|
||||
any thread → direct shared row writes ─────────────┘
|
||||
```
|
||||
|
||||
Messages allocate `{ name, rows, stride, format }` arrays and load graphs. Existing render data is changed by writing `f32`, `u32`, or `i32` rows directly. Allocations are 64-byte aligned, row strides are multiples of 16 bytes, and compatible non-overlapping transient textures share physical allocations.
|
||||
The arena starts with only one eight-float `signals` row for frame timing and render invalidation. Messages create or delete other `{ name, rows, stride, format }` arrays, allocate named slots, compile graphs, switch loadouts, and control render pacing. Existing render data is changed by writing `f32`, `u32`, or `i32` rows directly, then setting the shared-data dirty signal. Allocations are 64-byte aligned, row strides are multiples of 16 bytes, and compatible non-overlapping transient textures share physical allocations.
|
||||
|
||||
WGSL, pipelines, glTF import, and conventional mesh/camera/material handles live in `addons/`; core contains no shader or scene model.
|
||||
|
||||
Run the Go website, in-memory package server, SQLite-backed playground, and tutorial:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
npm run server
|
||||
```
|
||||
|
||||
This opens the docs. The complete runnable example is at `/playground`.
|
||||
The server rebuilds `@yawn/core` and `@yawn/handles` at startup and serves the public CDN modules at
|
||||
`https://yawn.heaust.org/pkg/core.js` and `https://yawn.heaust.org/pkg/handles.js`. Their workers and
|
||||
WASM stay on the CDN automatically, so applications only import the module URLs. The complete editor is at `/playground`; saved revisions use
|
||||
`/playground/{id}/{revision}` URLs. Set `PORT` or `YAWN_DATABASE` to override the default port and
|
||||
`server/data/yawn.db` database path.
|
||||
|
||||
Run the previous VitePress documentation site with `npm start`.
|
||||
|
||||
Run `npm run coredocs` for the raw worker-message, shared-memory, and render-graph reference intended for custom handles, editors, and direct SAB clients.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "@yawn/default-pipelines",
|
||||
"version": "0.1.0",
|
||||
"description": "Optional graph-authored scene, frame, and compute pipelines for Yawn",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js"
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
const triangleShader = /* wgsl */ `
|
||||
struct Tint { color: vec4<f32> }
|
||||
@group(0) @binding(0) var<uniform> tint: Tint;
|
||||
|
||||
struct Vertex { @builtin(position) position: vec4<f32> }
|
||||
|
||||
@vertex fn vertex(@builtin(vertex_index) index: u32) -> Vertex {
|
||||
let positions = array(vec2(-0.75, -0.65), vec2(0.75, -0.65), vec2(0.0, 0.75));
|
||||
var output: Vertex;
|
||||
output.position = vec4(positions[index], 0.0, 1.0);
|
||||
return output;
|
||||
}
|
||||
|
||||
@fragment fn fragment() -> @location(0) vec4<f32> { return tint.color; }
|
||||
`;
|
||||
|
||||
const noopComputeShader = /* wgsl */ `
|
||||
@compute @workgroup_size(1) fn main() {}
|
||||
`;
|
||||
|
||||
/** A complete external graph used by the minimal playground; core contains neither program. */
|
||||
export function triangleGraph(colorArray = "triangle.color") {
|
||||
return {
|
||||
id: "triangle",
|
||||
resources: {
|
||||
buffers: [{ id: "color", array: colorArray, usage: ["uniform"] }],
|
||||
},
|
||||
pipelines: {
|
||||
compute: [{ id: "prepare", code: noopComputeShader }],
|
||||
render: [{
|
||||
id: "triangle",
|
||||
code: triangleShader,
|
||||
vertex: { entry: "vertex" },
|
||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||
}],
|
||||
},
|
||||
passes: [
|
||||
{ id: "prepare", type: "compute", pipeline: "prepare", dispatch: [1, 1, 1] },
|
||||
{
|
||||
id: "draw",
|
||||
type: "render",
|
||||
pipeline: "triangle",
|
||||
after: ["prepare"],
|
||||
bindings: [{ group: 0, binding: 0, resource: "color" }],
|
||||
color: [{ resource: "canvas", clear: [0.025, 0.035, 0.055, 1] }],
|
||||
draw: { vertices: 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "@yawn/gltf-import",
|
||||
"version": "0.1.0",
|
||||
"description": "glTF worker that publishes generic render data directly into Yawn shared SOA memory",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js"
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
const GLB_MAGIC = 0x46546c67;
|
||||
const JSON_CHUNK = 0x4e4f534a;
|
||||
const BIN_CHUNK = 0x004e4942;
|
||||
const PACKET_MAGIC = 0x50445259;
|
||||
const PACKET_VERSION = 1;
|
||||
const COMPONENT_WIDTH = Object.freeze({ SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 });
|
||||
const COMPONENT_SIZE = Object.freeze({ 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 });
|
||||
const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const fail = code => { throw new Error(code); };
|
||||
const align4 = value => (value + 3) & ~3;
|
||||
const finite = values => values.every(Number.isFinite);
|
||||
|
||||
function parseContainer(source) {
|
||||
if (!(source instanceof Uint8Array) || !source.byteLength) fail("GLTF_EMPTY");
|
||||
const view = new DataView(source.buffer, source.byteOffset, source.byteLength);
|
||||
if (source.byteLength >= 12 && view.getUint32(0, true) === GLB_MAGIC) {
|
||||
if (view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== source.byteLength)
|
||||
fail("GLTF_INVALID_CONTAINER");
|
||||
let offset = 12, json, binary;
|
||||
while (offset < source.byteLength) {
|
||||
if (offset + 8 > source.byteLength) fail("GLTF_INVALID_CONTAINER");
|
||||
const length = view.getUint32(offset, true);
|
||||
const type = view.getUint32(offset + 4, true);
|
||||
const end = offset + 8 + length;
|
||||
if (end > source.byteLength) fail("GLTF_INVALID_CONTAINER");
|
||||
const chunk = source.subarray(offset + 8, end);
|
||||
if (type === JSON_CHUNK && !json) json = chunk;
|
||||
if (type === BIN_CHUNK && !binary) binary = chunk;
|
||||
offset = end;
|
||||
}
|
||||
if (!json) fail("GLTF_JSON_MISSING");
|
||||
return { document: JSON.parse(decoder.decode(json).replace(/\0+$/u, "").trimEnd()), binary };
|
||||
}
|
||||
return { document: JSON.parse(decoder.decode(source).replace(/^\uFEFF/u, "")), binary: undefined };
|
||||
}
|
||||
|
||||
async function fetchBytes(uri, baseUrl, fetcher) {
|
||||
const response = await fetcher(new URL(uri, baseUrl));
|
||||
if (!response.ok) fail(`HTTP_${response.status}`);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function loadBuffers(document, binary, baseUrl, fetcher) {
|
||||
return Promise.all((document.buffers ?? []).map(async (buffer, index) => {
|
||||
const bytes = buffer.uri === undefined
|
||||
? (index === 0 ? binary : undefined)
|
||||
: await fetchBytes(buffer.uri, baseUrl, fetcher);
|
||||
if (!bytes || bytes.byteLength < buffer.byteLength) fail("GLTF_BUFFER_INVALID");
|
||||
return bytes;
|
||||
}));
|
||||
}
|
||||
|
||||
function component(data, offset, type) {
|
||||
switch (type) {
|
||||
case 5120: return data.getInt8(offset);
|
||||
case 5121: return data.getUint8(offset);
|
||||
case 5122: return data.getInt16(offset, true);
|
||||
case 5123: return data.getUint16(offset, true);
|
||||
case 5125: return data.getUint32(offset, true);
|
||||
case 5126: return data.getFloat32(offset, true);
|
||||
default: fail("GLTF_ACCESSOR_COMPONENT");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeComponent(value, type) {
|
||||
switch (type) {
|
||||
case 5120: return Math.max(value / 127, -1);
|
||||
case 5121: return value / 255;
|
||||
case 5122: return Math.max(value / 32767, -1);
|
||||
case 5123: return value / 65535;
|
||||
case 5125: return value / 4294967295;
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
function viewBytes(document, buffers, index) {
|
||||
const view = document.bufferViews?.[index];
|
||||
const buffer = view && buffers[view.buffer];
|
||||
if (!view || !buffer) fail("GLTF_BUFFER_VIEW_INVALID");
|
||||
const start = view.byteOffset ?? 0;
|
||||
const end = start + view.byteLength;
|
||||
if (end > buffer.byteLength) fail("GLTF_BUFFER_VIEW_INVALID");
|
||||
return { view, bytes: buffer.subarray(start, end) };
|
||||
}
|
||||
|
||||
function readAccessor(document, buffers, index, { integer = false } = {}) {
|
||||
const accessor = document.accessors?.[index];
|
||||
const width = accessor && COMPONENT_WIDTH[accessor.type];
|
||||
const size = accessor && COMPONENT_SIZE[accessor.componentType];
|
||||
if (!accessor || !width || !size || !Number.isInteger(accessor.count) || accessor.count < 0)
|
||||
fail("GLTF_ACCESSOR_INVALID");
|
||||
const values = integer ? new Uint32Array(accessor.count * width) : new Float32Array(accessor.count * width);
|
||||
if (accessor.bufferView !== undefined) {
|
||||
const { view, bytes } = viewBytes(document, buffers, accessor.bufferView);
|
||||
const stride = view.byteStride ?? width * size;
|
||||
const start = accessor.byteOffset ?? 0;
|
||||
if (stride < width * size || start + Math.max(0, accessor.count - 1) * stride + width * size > bytes.byteLength)
|
||||
fail("GLTF_ACCESSOR_RANGE");
|
||||
const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
for (let item = 0; item < accessor.count; item++) {
|
||||
for (let lane = 0; lane < width; lane++) {
|
||||
let value = component(data, start + item * stride + lane * size, accessor.componentType);
|
||||
if (!integer && accessor.normalized) value = normalizeComponent(value, accessor.componentType);
|
||||
values[item * width + lane] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (accessor.sparse) {
|
||||
const sparse = accessor.sparse;
|
||||
const indices = sparse.indices;
|
||||
const indexSize = COMPONENT_SIZE[indices.componentType];
|
||||
if (!indexSize || ![5121, 5123, 5125].includes(indices.componentType)) fail("GLTF_SPARSE_INVALID");
|
||||
const indexView = viewBytes(document, buffers, indices.bufferView).bytes;
|
||||
const valueView = viewBytes(document, buffers, sparse.values.bufferView).bytes;
|
||||
const indexStart = indices.byteOffset ?? 0;
|
||||
const valueStart = sparse.values.byteOffset ?? 0;
|
||||
if (indexStart + sparse.count * indexSize > indexView.byteLength || valueStart + sparse.count * width * size > valueView.byteLength)
|
||||
fail("GLTF_SPARSE_INVALID");
|
||||
const indexData = new DataView(indexView.buffer, indexView.byteOffset, indexView.byteLength);
|
||||
const valueData = new DataView(valueView.buffer, valueView.byteOffset, valueView.byteLength);
|
||||
for (let item = 0; item < sparse.count; item++) {
|
||||
const target = component(indexData, indexStart + item * indexSize, indices.componentType);
|
||||
if (target >= accessor.count) fail("GLTF_SPARSE_INVALID");
|
||||
for (let lane = 0; lane < width; lane++) {
|
||||
let value = component(valueData, valueStart + (item * width + lane) * size, accessor.componentType);
|
||||
if (!integer && accessor.normalized) value = normalizeComponent(value, accessor.componentType);
|
||||
values[target * width + lane] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!finite(values)) fail("GLTF_ACCESSOR_NONFINITE");
|
||||
return { count: accessor.count, width, values };
|
||||
}
|
||||
|
||||
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
function normalize(value, fallback) {
|
||||
const length = Math.hypot(...value);
|
||||
return length > Number.EPSILON && Number.isFinite(length) ? value.map(item => item / length) : fallback;
|
||||
}
|
||||
function lanes(values, index, width) { return Array.from(values.subarray(index * width, (index + 1) * width)); }
|
||||
|
||||
function repairGeometry(positions, normals, tangents, uvs, indices) {
|
||||
const count = positions.length / 3;
|
||||
if (indices.length % 3 || Array.from(indices).some(index => index >= count)) fail("GLTF_TRIANGLES_INVALID");
|
||||
const normalsValid = normals?.length === positions.length;
|
||||
const tangentsValid = tangents?.length === count * 4;
|
||||
if (normalsValid && tangentsValid)
|
||||
return { positions, normals, tangents, uvs, indices };
|
||||
const outPositions = [], outNormals = [], outTangents = [], outUvs = [];
|
||||
for (let triangle = 0; triangle < indices.length; triangle += 3) {
|
||||
const ids = [indices[triangle], indices[triangle + 1], indices[triangle + 2]];
|
||||
const p = ids.map(index => lanes(positions, index, 3));
|
||||
const uv = ids.map(index => lanes(uvs, index, 2));
|
||||
const faceNormal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0, 1, 0]);
|
||||
const duv1 = sub([...uv[1], 0], [...uv[0], 0]);
|
||||
const duv2 = sub([...uv[2], 0], [...uv[0], 0]);
|
||||
const determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
|
||||
const edge1 = sub(p[1], p[0]), edge2 = sub(p[2], p[0]);
|
||||
const rawTangent = Math.abs(determinant) > Number.EPSILON
|
||||
? edge1.map((value, lane) => (value * duv2[1] - edge2[lane] * duv1[1]) / determinant)
|
||||
: [0, 0, 0];
|
||||
const rawBitangent = Math.abs(determinant) > Number.EPSILON
|
||||
? edge2.map((value, lane) => (value * duv1[0] - edge1[lane] * duv2[0]) / determinant)
|
||||
: [0, 0, 0];
|
||||
for (let corner = 0; corner < 3; corner++) {
|
||||
const normal = normalize(normalsValid ? lanes(normals, ids[corner], 3) : faceNormal, faceNormal);
|
||||
const projected = rawTangent.map((value, lane) => value - normal[lane] * dot(normal, rawTangent));
|
||||
const axis = Math.abs(normal[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
|
||||
const tangent = normalize(projected, normalize(cross(axis, normal), [0, 0, 1]));
|
||||
const generated = [...tangent, dot(cross(normal, tangent), rawBitangent) < 0 ? -1 : 1];
|
||||
outPositions.push(...p[corner]);
|
||||
outNormals.push(...normal);
|
||||
outTangents.push(...(tangentsValid ? lanes(tangents, ids[corner], 4) : generated));
|
||||
outUvs.push(...uv[corner]);
|
||||
}
|
||||
}
|
||||
const repairedIndices = Uint32Array.from({ length: outPositions.length / 3 }, (_, index) => index);
|
||||
return {
|
||||
positions: new Float32Array(outPositions),
|
||||
normals: new Float32Array(outNormals),
|
||||
tangents: new Float32Array(outTangents),
|
||||
uvs: new Float32Array(outUvs),
|
||||
indices: repairedIndices,
|
||||
};
|
||||
}
|
||||
|
||||
function multiply(a, b) {
|
||||
const result = Array(16).fill(0);
|
||||
for (let column = 0; column < 4; column++)
|
||||
for (let row = 0; row < 4; row++)
|
||||
for (let lane = 0; lane < 4; lane++)
|
||||
result[column * 4 + row] += a[lane * 4 + row] * b[column * 4 + lane];
|
||||
return result;
|
||||
}
|
||||
|
||||
function nodeMatrix(node) {
|
||||
if (node.matrix) {
|
||||
if (node.matrix.length !== 16 || !finite(node.matrix)) fail("GLTF_NODE_TRANSFORM");
|
||||
return Array.from(node.matrix);
|
||||
}
|
||||
const [x, y, z, w] = node.rotation ?? [0, 0, 0, 1];
|
||||
const [sx, sy, sz] = node.scale ?? [1, 1, 1];
|
||||
const [tx, ty, tz] = node.translation ?? [0, 0, 0];
|
||||
const matrix = [
|
||||
(1 - 2 * y * y - 2 * z * z) * sx, (2 * x * y + 2 * z * w) * sx, (2 * x * z - 2 * y * w) * sx, 0,
|
||||
(2 * x * y - 2 * z * w) * sy, (1 - 2 * x * x - 2 * z * z) * sy, (2 * y * z + 2 * x * w) * sy, 0,
|
||||
(2 * x * z + 2 * y * w) * sz, (2 * y * z - 2 * x * w) * sz, (1 - 2 * x * x - 2 * y * y) * sz, 0,
|
||||
tx, ty, tz, 1,
|
||||
];
|
||||
if (!finite(matrix)) fail("GLTF_NODE_TRANSFORM");
|
||||
return matrix;
|
||||
}
|
||||
|
||||
function textureReference(reference) {
|
||||
return reference ? { texture: reference.index, texCoord: reference.texCoord ?? 0 } : null;
|
||||
}
|
||||
|
||||
function materialMetadata(material, index) {
|
||||
const pbr = material.pbrMetallicRoughness ?? {};
|
||||
const ior = material.extensions?.KHR_materials_ior?.ior ?? 1.5;
|
||||
if (!Number.isFinite(ior) || (ior !== 0 && ior < 1)) fail("GLTF_MATERIAL_IOR");
|
||||
return {
|
||||
key: index + 1,
|
||||
baseColorFactor: pbr.baseColorFactor ?? [1, 1, 1, 1],
|
||||
metallicFactor: pbr.metallicFactor ?? 1,
|
||||
roughnessFactor: pbr.roughnessFactor ?? 1,
|
||||
emissiveFactor: material.emissiveFactor ?? [0, 0, 0],
|
||||
ior,
|
||||
alphaMode: (material.alphaMode ?? "OPAQUE").toLowerCase(),
|
||||
alphaCutoff: material.alphaCutoff ?? 0.5,
|
||||
doubleSided: material.doubleSided ?? false,
|
||||
baseColorTexture: textureReference(pbr.baseColorTexture),
|
||||
metallicRoughnessTexture: textureReference(pbr.metallicRoughnessTexture),
|
||||
normalTexture: textureReference(material.normalTexture),
|
||||
normalScale: material.normalTexture?.scale ?? 1,
|
||||
occlusionTexture: textureReference(material.occlusionTexture),
|
||||
occlusionStrength: material.occlusionTexture?.strength ?? 1,
|
||||
emissiveTexture: textureReference(material.emissiveTexture),
|
||||
};
|
||||
}
|
||||
|
||||
function samplerMetadata(sampler) {
|
||||
const min = sampler.minFilter;
|
||||
return {
|
||||
magFilter: sampler.magFilter === 9728 ? "nearest" : "linear",
|
||||
minFilter: [9728, 9984, 9986].includes(min) ? "nearest" : "linear",
|
||||
mipmapFilter: [9984, 9985].includes(min) ? "nearest" : "linear",
|
||||
addressU: sampler.wrapS === 33071 ? "clamp_to_edge" : sampler.wrapS === 33648 ? "mirror_repeat" : "repeat",
|
||||
addressV: sampler.wrapT === 33071 ? "clamp_to_edge" : sampler.wrapT === 33648 ? "mirror_repeat" : "repeat",
|
||||
};
|
||||
}
|
||||
|
||||
function inferMime(image) {
|
||||
if (image.mimeType) return image.mimeType;
|
||||
const uri = image.uri?.toLowerCase() ?? "";
|
||||
if (uri.startsWith("data:image/png") || uri.endsWith(".png")) return "image/png";
|
||||
if (uri.startsWith("data:image/jpeg") || /\.jpe?g(?:$|[?#])/u.test(uri)) return "image/jpeg";
|
||||
fail("GLTF_IMAGE_MIME");
|
||||
}
|
||||
|
||||
async function decodeScene(document, buffers, baseUrl, fetcher) {
|
||||
const geometries = [], occurrences = [], geometryIds = new Map();
|
||||
for (let meshIndex = 0; meshIndex < (document.meshes ?? []).length; meshIndex++) {
|
||||
const mesh = document.meshes[meshIndex];
|
||||
for (let primitiveIndex = 0; primitiveIndex < (mesh.primitives ?? []).length; primitiveIndex++) {
|
||||
const primitive = mesh.primitives[primitiveIndex];
|
||||
if ((primitive.mode ?? 4) !== 4 || primitive.attributes?.POSITION === undefined) fail("GLTF_TRIANGLES_REQUIRED");
|
||||
const position = readAccessor(document, buffers, primitive.attributes.POSITION);
|
||||
if (position.width !== 3 || !position.count) fail("GLTF_POSITION_INVALID");
|
||||
const normal = primitive.attributes.NORMAL === undefined ? null : readAccessor(document, buffers, primitive.attributes.NORMAL);
|
||||
const tangent = primitive.attributes.TANGENT === undefined ? null : readAccessor(document, buffers, primitive.attributes.TANGENT);
|
||||
const texcoord = primitive.attributes.TEXCOORD_0 === undefined ? null : readAccessor(document, buffers, primitive.attributes.TEXCOORD_0);
|
||||
if (normal && normal.width !== 3 || tangent && tangent.width !== 4 || texcoord && texcoord.width !== 2)
|
||||
fail("GLTF_ATTRIBUTE_INVALID");
|
||||
const uvs = new Float32Array(position.count * 2);
|
||||
if (texcoord) uvs.set(texcoord.values.subarray(0, uvs.length));
|
||||
const indexAccessor = primitive.indices === undefined
|
||||
? null
|
||||
: readAccessor(document, buffers, primitive.indices, { integer: true });
|
||||
if (indexAccessor && indexAccessor.width !== 1) fail("GLTF_INDEX_INVALID");
|
||||
const indices = indexAccessor?.values
|
||||
?? Uint32Array.from({ length: position.count }, (_, index) => index);
|
||||
const repaired = repairGeometry(position.values, normal?.values, tangent?.values, uvs, indices);
|
||||
const id = geometries.length;
|
||||
geometryIds.set(`${meshIndex}:${primitiveIndex}`, id);
|
||||
const material = primitive.material === undefined ? undefined : document.materials?.[primitive.material];
|
||||
geometries.push({
|
||||
id,
|
||||
material: primitive.material === undefined ? 0 : primitive.material + 1,
|
||||
instanceType: [1 | 4 | (material?.doubleSided ? 8 : 0), ...Array(15).fill(0)],
|
||||
...repaired,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const children = new Set((document.nodes ?? []).flatMap(node => node.children ?? []));
|
||||
const scene = document.scenes?.[document.scene ?? 0];
|
||||
const roots = scene?.nodes ?? (document.nodes ?? []).map((_, index) => index).filter(index => !children.has(index));
|
||||
const active = new Set();
|
||||
const visit = (nodeIndex, parent) => {
|
||||
const node = document.nodes?.[nodeIndex];
|
||||
if (!node || active.has(nodeIndex)) fail("GLTF_NODE_INVALID");
|
||||
active.add(nodeIndex);
|
||||
const world = multiply(parent, nodeMatrix(node));
|
||||
if (node.mesh !== undefined) {
|
||||
const mesh = document.meshes?.[node.mesh];
|
||||
if (!mesh) fail("GLTF_MESH_INVALID");
|
||||
for (let primitive = 0; primitive < mesh.primitives.length; primitive++) {
|
||||
const geometry = geometryIds.get(`${node.mesh}:${primitive}`);
|
||||
if (geometry !== undefined) occurrences.push({ geometry, transform: world });
|
||||
}
|
||||
}
|
||||
for (const child of node.children ?? []) visit(child, world);
|
||||
active.delete(nodeIndex);
|
||||
};
|
||||
for (const root of roots) visit(root, IDENTITY);
|
||||
|
||||
const images = await Promise.all((document.images ?? []).map(async image => {
|
||||
const data = image.bufferView === undefined
|
||||
? await fetchBytes(image.uri, baseUrl, fetcher)
|
||||
: viewBytes(document, buffers, image.bufferView).bytes.slice();
|
||||
return { mimeType: inferMime(image), bytes: data };
|
||||
}));
|
||||
return {
|
||||
geometries,
|
||||
occurrences,
|
||||
materials: [materialMetadata({}, -1), ...(document.materials ?? []).map(materialMetadata)],
|
||||
textures: (document.textures ?? []).map(texture => ({ image: texture.source, sampler: texture.sampler ?? null })),
|
||||
samplers: (document.samplers ?? []).map(samplerMetadata),
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
function encodePacket(scene) {
|
||||
const chunks = [];
|
||||
let payloadLength = 0;
|
||||
const append = (source, alignment = 4) => {
|
||||
const bytes = source instanceof Uint8Array
|
||||
? source
|
||||
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
||||
const offset = alignment === 4 ? align4(payloadLength) : payloadLength;
|
||||
if (offset > payloadLength) chunks.push({ offset: payloadLength, bytes: new Uint8Array(offset - payloadLength) });
|
||||
chunks.push({ offset, bytes });
|
||||
payloadLength = offset + bytes.byteLength;
|
||||
return offset;
|
||||
};
|
||||
const stream = (values, width) => ({ offset: append(values), count: values.length / width });
|
||||
const metadata = {
|
||||
geometries: scene.geometries.map(geometry => ({
|
||||
id: geometry.id,
|
||||
material: geometry.material,
|
||||
instanceType: geometry.instanceType,
|
||||
positions: stream(geometry.positions, 3),
|
||||
normals: stream(geometry.normals, 3),
|
||||
tangents: stream(geometry.tangents, 4),
|
||||
uvs: stream(geometry.uvs, 2),
|
||||
indices: stream(geometry.indices, 1),
|
||||
})),
|
||||
occurrences: scene.occurrences,
|
||||
materials: scene.materials,
|
||||
textures: scene.textures,
|
||||
samplers: scene.samplers,
|
||||
images: scene.images.map(image => ({
|
||||
mimeType: image.mimeType,
|
||||
data: { offset: append(image.bytes), byteLength: image.bytes.byteLength },
|
||||
})),
|
||||
};
|
||||
const metadataBytes = encoder.encode(JSON.stringify(metadata));
|
||||
const payloadOffset = align4(16 + metadataBytes.byteLength);
|
||||
const packet = new Uint8Array(payloadOffset + payloadLength);
|
||||
const header = new DataView(packet.buffer);
|
||||
header.setUint32(0, PACKET_MAGIC, true);
|
||||
header.setUint32(4, PACKET_VERSION, true);
|
||||
header.setUint32(8, metadataBytes.byteLength, true);
|
||||
header.setUint32(12, payloadLength, true);
|
||||
packet.set(metadataBytes, 16);
|
||||
for (const chunk of chunks) packet.set(chunk.bytes, payloadOffset + chunk.offset);
|
||||
return packet;
|
||||
}
|
||||
|
||||
/** Convert glTF 2.0/GLB bytes into Yawn's format-neutral render-data packet. */
|
||||
export async function gltfToRenderDataPacket(source, baseUrl, fetcher = fetch) {
|
||||
const { document, binary } = parseContainer(source);
|
||||
if (document.asset?.version !== "2.0") fail("GLTF_VERSION_UNSUPPORTED");
|
||||
const buffers = await loadBuffers(document, binary, baseUrl, fetcher);
|
||||
return encodePacket(await decodeScene(document, buffers, baseUrl, fetcher));
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/** Fetches and parses glTF in a worker, then lets that worker write the packet into Yawn's SAB arena. */
|
||||
export class GltfImporter {
|
||||
#core;
|
||||
#worker;
|
||||
#next = 1;
|
||||
#pending = new Map();
|
||||
|
||||
constructor(core, { workerFactory } = {}) {
|
||||
if (!core?.allocateRows) throw new TypeError("core must be a YawnCore instance");
|
||||
this.#core = core;
|
||||
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-gltf-import",
|
||||
});
|
||||
this.#worker.addEventListener("message", ({ data }) => this.#message(data));
|
||||
this.#worker.addEventListener("error", () => this.#fail("GLTF_WORKER_ERROR"));
|
||||
this.#worker.start?.();
|
||||
}
|
||||
|
||||
load(url) {
|
||||
const source = url instanceof URL ? url.href : url;
|
||||
if (typeof source !== "string" || !source) throw new TypeError("url is required");
|
||||
const request = this.#next++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pending.set(request, { resolve, reject });
|
||||
this.#worker.postMessage({ type: "load", request, url: source });
|
||||
});
|
||||
}
|
||||
|
||||
async #message(message) {
|
||||
const pending = this.#pending.get(message?.request);
|
||||
if (!pending) return;
|
||||
try {
|
||||
if (message.type === "allocate") {
|
||||
pending.array = await this.#core.allocateRows({
|
||||
name: `gltf.${message.request}`,
|
||||
rows: Math.ceil(message.byteLength / 16),
|
||||
stride: 16,
|
||||
format: "u32",
|
||||
});
|
||||
this.#worker.postMessage({ type: "storage", request: message.request, ...pending.array.share() });
|
||||
} else {
|
||||
this.#pending.delete(message.request);
|
||||
if (message.type === "ready") pending.resolve({ array: pending.array, byteLength: message.byteLength });
|
||||
else pending.reject(new Error(message.error ?? "GLTF_IMPORT_FAILED"));
|
||||
}
|
||||
} catch (error) {
|
||||
this.#pending.delete(message.request);
|
||||
pending.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
#fail(code) {
|
||||
for (const { reject } of this.#pending.values()) reject(new Error(code));
|
||||
this.#pending.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#fail("DISPOSED");
|
||||
this.#worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { gltfToRenderDataPacket } from "./gltf.js";
|
||||
|
||||
const downloads = new Map();
|
||||
|
||||
addEventListener("message", async ({ data: message }) => {
|
||||
const request = message?.request;
|
||||
try {
|
||||
if (message?.type === "load") {
|
||||
const response = await fetch(message.url);
|
||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (!bytes.byteLength) throw new Error("GLTF_EMPTY");
|
||||
const packet = await gltfToRenderDataPacket(bytes, message.url);
|
||||
downloads.set(request, packet);
|
||||
postMessage({ type: "allocate", request, byteLength: packet.byteLength });
|
||||
return;
|
||||
}
|
||||
if (message?.type === "storage") {
|
||||
const packet = downloads.get(request);
|
||||
if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN");
|
||||
const { buffer, descriptor } = message;
|
||||
if (!(buffer instanceof SharedArrayBuffer) || descriptor?.format !== "u32" ||
|
||||
descriptor.stride !== 16 || descriptor.offset % 64 || packet.byteLength > descriptor.rows * descriptor.stride)
|
||||
throw new Error("GLTF_STORAGE_INVALID");
|
||||
new Uint8Array(buffer, descriptor.offset, packet.byteLength).set(packet);
|
||||
downloads.delete(request);
|
||||
postMessage({ type: "ready", request, byteLength: packet.byteLength });
|
||||
}
|
||||
} catch (error) {
|
||||
downloads.delete(request);
|
||||
postMessage({ type: "error", request, error: error?.message || "GLTF_IMPORT_FAILED" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@yawn/handles",
|
||||
"version": "0.1.0",
|
||||
"description": "Conventional scene handles over Yawn's worker and shared render data",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@yawn/core": "0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
export type GraphBuffer = {
|
||||
id: string;
|
||||
array: string;
|
||||
usage?: string[];
|
||||
sync?: "frame" | "loadout";
|
||||
};
|
||||
|
||||
export type GraphTexture = {
|
||||
id: string;
|
||||
format?: string;
|
||||
size?: [number | "canvas", number | "canvas", number?];
|
||||
usage?: string[];
|
||||
mipLevelCount?: number;
|
||||
transient?: boolean;
|
||||
};
|
||||
|
||||
export type GraphSampler = {
|
||||
id: string;
|
||||
magFilter?: "nearest" | "linear";
|
||||
minFilter?: "nearest" | "linear";
|
||||
mipmapFilter?: "nearest" | "linear";
|
||||
addressModeU?: "clamp-to-edge" | "repeat" | "mirror-repeat";
|
||||
addressModeV?: "clamp-to-edge" | "repeat" | "mirror-repeat";
|
||||
anisotropyClamp?: number;
|
||||
};
|
||||
|
||||
export type GraphBinding = {
|
||||
group: number;
|
||||
binding: number;
|
||||
resource: string;
|
||||
};
|
||||
|
||||
export type ComputePassOptions = {
|
||||
id?: string;
|
||||
code: string;
|
||||
entry?: string;
|
||||
dispatch?: [number, number?, number?];
|
||||
after?: string[];
|
||||
bindings?: GraphBinding[];
|
||||
buffers?: GraphBuffer[];
|
||||
textures?: GraphTexture[];
|
||||
samplers?: GraphSampler[];
|
||||
};
|
||||
|
||||
let nextComputePass = 1;
|
||||
|
||||
/** A graph-authored compute pass; attaching or updating it rebuilds the Scene loadout. */
|
||||
export class ComputePass {
|
||||
readonly id: string;
|
||||
code: string;
|
||||
entry: string;
|
||||
dispatch: [number, number, number];
|
||||
after: string[];
|
||||
bindings: GraphBinding[];
|
||||
buffers: GraphBuffer[];
|
||||
textures: GraphTexture[];
|
||||
samplers: GraphSampler[];
|
||||
#scene?: Scene;
|
||||
|
||||
constructor(options: ComputePassOptions) {
|
||||
if (!options?.code) throw new TypeError("ComputePass code is required");
|
||||
this.id = options.id ?? `compute-${nextComputePass++}`;
|
||||
this.code = options.code;
|
||||
this.entry = options.entry ?? "main";
|
||||
this.dispatch = [
|
||||
options.dispatch?.[0] ?? 1,
|
||||
options.dispatch?.[1] ?? 1,
|
||||
options.dispatch?.[2] ?? 1,
|
||||
];
|
||||
this.after = [...(options.after ?? [])];
|
||||
this.bindings = [...(options.bindings ?? [])];
|
||||
this.buffers = [...(options.buffers ?? [])];
|
||||
this.textures = [...(options.textures ?? [])];
|
||||
this.samplers = [...(options.samplers ?? [])];
|
||||
}
|
||||
|
||||
update(options: Partial<Omit<ComputePassOptions, "id">>) {
|
||||
if (options.code !== undefined) this.code = options.code;
|
||||
if (options.entry !== undefined) this.entry = options.entry;
|
||||
if (options.dispatch !== undefined) this.dispatch = [
|
||||
options.dispatch[0],
|
||||
options.dispatch[1] ?? 1,
|
||||
options.dispatch[2] ?? 1,
|
||||
];
|
||||
if (options.after !== undefined) this.after = [...options.after];
|
||||
if (options.bindings !== undefined) this.bindings = [...options.bindings];
|
||||
if (options.buffers !== undefined) this.buffers = [...options.buffers];
|
||||
if (options.textures !== undefined) this.textures = [...options.textures];
|
||||
if (options.samplers !== undefined) this.samplers = [...options.samplers];
|
||||
return this.#scene?.updateRenderGraph() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
attach(scene?: Scene) {
|
||||
this.#scene = scene;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Node, type NodeOptions } from "./Node";
|
||||
import type { Scene } from "./Scene";
|
||||
import type { PBRMaterial } from "./materials/PBRMaterial";
|
||||
|
||||
export const VertexKinds = Object.freeze(["positions", "normals", "tangents", "uvs", "colors", "indices"] as const);
|
||||
export type VertexKind = (typeof VertexKinds)[number];
|
||||
export type MeshOptions = NodeOptions & {
|
||||
geometryId?: number;
|
||||
material?: PBRMaterial;
|
||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
/** A renderable Node; clones share geometry until either clone mutates vertex data. */
|
||||
export class Mesh extends Node {
|
||||
geometryId: number;
|
||||
vertexCount = 0;
|
||||
indexCount = 0;
|
||||
instanceOf = -1;
|
||||
readonly faceMaterials = new Map<number, number>();
|
||||
#registered = false;
|
||||
|
||||
constructor(scene: Scene, options: MeshOptions = {}) {
|
||||
super(scene, options);
|
||||
this.geometryId = options.geometryId ?? scene.createGeometry();
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
if (options.material) await options.material.ready;
|
||||
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
|
||||
this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3;
|
||||
this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0);
|
||||
this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId;
|
||||
scene.array("meshInfo").write(this.id, [
|
||||
this.geometryId,
|
||||
options.material?.id ?? 0,
|
||||
options.visible === false ? 0 : 1,
|
||||
this.instanceOf,
|
||||
]);
|
||||
for (const [kind, data] of Object.entries(options.vertexData ?? {}))
|
||||
await this.#setVertexData(kind as VertexKind, data!, false);
|
||||
this.#writeBounds();
|
||||
this.#registered = true;
|
||||
await scene.registerMesh(this);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
if (this.id < 0) throw new Error("Await mesh.ready before reading it");
|
||||
return this.scene.array("meshInfo").row(this.id)[2] !== 0;
|
||||
}
|
||||
|
||||
set isVisible(value: boolean) {
|
||||
if (this.id < 0) throw new Error("Await mesh.ready before writing it");
|
||||
this.scene.array("meshInfo").row(this.id)[2] = value ? 1 : 0;
|
||||
}
|
||||
|
||||
get materialId() {
|
||||
return this.scene.array("meshInfo").row(this.id)[1];
|
||||
}
|
||||
|
||||
set material(value: PBRMaterial) {
|
||||
if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene");
|
||||
this.scene.markDirty(true);
|
||||
this.scene.array("meshInfo").row(this.id)[1] = value.id;
|
||||
void this.scene.updateRenderGraph().catch(() => undefined);
|
||||
}
|
||||
|
||||
clone(options: Omit<MeshOptions, "geometryId" | "vertexData"> = {}) {
|
||||
if (this.id < 0) throw new Error("Await mesh.ready before cloning it");
|
||||
return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
|
||||
}
|
||||
|
||||
async setVertexData(kind: VertexKind, data: ArrayLike<number>) {
|
||||
if (!VertexKinds.includes(kind)) throw new TypeError(`Unknown vertex kind: ${kind}`);
|
||||
await this.ready;
|
||||
await this.#setVertexData(kind, data, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
async #setVertexData(kind: VertexKind, data: ArrayLike<number>, makeUnique: boolean) {
|
||||
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
|
||||
const original = this.geometryId;
|
||||
this.geometryId = await this.scene.cloneGeometry(original);
|
||||
this.scene.releaseGeometry(original);
|
||||
this.scene.referenceGeometry(this.geometryId);
|
||||
this.instanceOf = this.id;
|
||||
this.scene.markDirty(true);
|
||||
this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, this.id]);
|
||||
}
|
||||
if (kind === "positions") this.vertexCount = data.length / 3;
|
||||
if (kind === "indices") this.indexCount = data.length;
|
||||
await this.scene.setVertexData(this.geometryId, kind, data, makeUnique);
|
||||
if (kind === "positions") this.#writeBounds();
|
||||
}
|
||||
|
||||
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
|
||||
await Promise.all([this.ready, material.ready]);
|
||||
if (material.scene !== this.scene) throw new Error("Material must belong to the same Scene");
|
||||
const list = Array.isArray(faces) ? faces : [faces];
|
||||
const maximum = Math.max(...list);
|
||||
const array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32");
|
||||
this.scene.markDirty(true);
|
||||
for (const face of list) {
|
||||
if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
|
||||
array.row(face)[0] = material.id + 1;
|
||||
this.faceMaterials.set(face, material.id);
|
||||
}
|
||||
await this.scene.updateRenderGraph();
|
||||
return this;
|
||||
}
|
||||
|
||||
#writeBounds() {
|
||||
const positions = this.scene.geometryData(this.geometryId, "positions");
|
||||
if (!positions?.length || this.id < 0) return;
|
||||
const minimum = [Infinity, Infinity, Infinity];
|
||||
const maximum = [-Infinity, -Infinity, -Infinity];
|
||||
for (let index = 0; index < positions.length; index += 3)
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
minimum[lane] = Math.min(minimum[lane], positions[index + lane]);
|
||||
maximum[lane] = Math.max(maximum[lane], positions[index + lane]);
|
||||
}
|
||||
this.scene.array("bounds").row(this.id).set([...minimum, 0, ...maximum, 0]);
|
||||
}
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
if (this.#registered) {
|
||||
this.#registered = false;
|
||||
await this.scene.unregisterMesh(this);
|
||||
}
|
||||
await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
export type NodeOptions = {
|
||||
position?: ArrayLike<number>;
|
||||
rotor?: ArrayLike<number>;
|
||||
scale?: ArrayLike<number>;
|
||||
parent?: Node;
|
||||
};
|
||||
|
||||
function vector(value: ArrayLike<number>, width: number, name: string) {
|
||||
const lanes = Array.from(value);
|
||||
if (lanes.length !== width || lanes.some((lane) => !Number.isFinite(lane)))
|
||||
throw new TypeError(name);
|
||||
return lanes;
|
||||
}
|
||||
|
||||
/** Three shared transform components. Component writes go straight to the backing SAB row. */
|
||||
export interface Vector3 extends Iterable<number> {
|
||||
[lane: number]: number;
|
||||
readonly length: 3;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
toArray(): [number, number, number];
|
||||
}
|
||||
|
||||
/** Four shared rotor components in `[x, y, z, w]` order. */
|
||||
export interface Rotor extends Iterable<number> {
|
||||
[lane: number]: number;
|
||||
readonly length: 4;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
toArray(): [number, number, number, number];
|
||||
}
|
||||
|
||||
abstract class XYZView {
|
||||
[lane: number]: number;
|
||||
abstract readonly length: 3 | 4;
|
||||
|
||||
constructor(
|
||||
private readonly row: () => Float32Array,
|
||||
private readonly changed: () => void,
|
||||
) {}
|
||||
|
||||
protected read(lane: number) { return this.row()[lane]; }
|
||||
protected write(lane: number, value: number) {
|
||||
this.row()[lane] = value;
|
||||
this.changed();
|
||||
}
|
||||
|
||||
get 0() { return this.read(0); }
|
||||
set 0(value: number) { this.write(0, value); }
|
||||
get 1() { return this.read(1); }
|
||||
set 1(value: number) { this.write(1, value); }
|
||||
get 2() { return this.read(2); }
|
||||
set 2(value: number) { this.write(2, value); }
|
||||
get x() { return this.read(0); }
|
||||
set x(value: number) { this.write(0, value); }
|
||||
get y() { return this.read(1); }
|
||||
set y(value: number) { this.write(1, value); }
|
||||
get z() { return this.read(2); }
|
||||
set z(value: number) { this.write(2, value); }
|
||||
|
||||
abstract toArray(): number[];
|
||||
[Symbol.iterator]() { return this.toArray().values(); }
|
||||
}
|
||||
|
||||
class Vector3View extends XYZView implements Vector3 {
|
||||
readonly length = 3;
|
||||
toArray(): [number, number, number] { return [this.x, this.y, this.z]; }
|
||||
}
|
||||
|
||||
class RotorView extends XYZView implements Rotor {
|
||||
readonly length = 4;
|
||||
get 3() { return this.read(3); }
|
||||
set 3(value: number) { this.write(3, value); }
|
||||
get w() { return this.read(3); }
|
||||
set w(value: number) { this.write(3, value); }
|
||||
toArray(): [number, number, number, number] {
|
||||
return [this.x, this.y, this.z, this.w];
|
||||
}
|
||||
}
|
||||
|
||||
/** A thin index into transform SOA rows; transform changes never post a worker message. */
|
||||
export class Node {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
ready: Promise<this>;
|
||||
protected disposed = false;
|
||||
readonly #position = new Vector3View(
|
||||
() => this.#row("nodePositions").subarray(0, 3),
|
||||
() => this.transformChanged(),
|
||||
);
|
||||
readonly #rotor = new RotorView(
|
||||
() => this.#row("nodeRotors").subarray(0, 4),
|
||||
() => this.transformChanged(),
|
||||
);
|
||||
readonly #scale = new Vector3View(
|
||||
() => this.#row("nodeScales").subarray(0, 3),
|
||||
() => this.transformChanged(),
|
||||
);
|
||||
|
||||
constructor(scene: Scene, options: NodeOptions = {}) {
|
||||
this.scene = scene;
|
||||
this.ready = scene.allocateNode().then((id) => {
|
||||
this.id = id;
|
||||
if (options.position) this.setPosition(options.position);
|
||||
if (options.rotor) this.setRotor(options.rotor);
|
||||
if (options.scale) this.setScale(options.scale);
|
||||
if (options.parent) this.parent = options.parent;
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
#row(name: string) {
|
||||
if (this.id < 0) throw new Error("Await node.ready before reading or writing it");
|
||||
return this.scene.array(name).row(this.id);
|
||||
}
|
||||
|
||||
get position(): Vector3 { return this.#position; }
|
||||
set position(value: ArrayLike<number>) { this.setPosition(value); }
|
||||
|
||||
get rotor(): Rotor { return this.#rotor; }
|
||||
set rotor(value: ArrayLike<number>) { this.setRotor(value); }
|
||||
|
||||
get scale(): Vector3 { return this.#scale; }
|
||||
set scale(value: ArrayLike<number>) { this.setScale(value); }
|
||||
|
||||
/** Replaces all position components with one SAB row write. */
|
||||
setPosition(value: ArrayLike<number>) {
|
||||
this.#row("nodePositions").set(vector(value, 3, "position"));
|
||||
this.transformChanged();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Replaces all rotor components with one SAB row write. */
|
||||
setRotor(value: ArrayLike<number>) {
|
||||
this.#row("nodeRotors").set(vector(value, 4, "rotor"));
|
||||
this.transformChanged();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Replaces all scale components with one SAB row write. */
|
||||
setScale(value: ArrayLike<number>) {
|
||||
this.#row("nodeScales").set(vector(value, 3, "scale"));
|
||||
this.transformChanged();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Adds an offset to the shared position. */
|
||||
translate(offset: ArrayLike<number>) {
|
||||
const [x, y, z] = vector(offset, 3, "translation");
|
||||
return this.setPosition([
|
||||
this.position.x + x,
|
||||
this.position.y + y,
|
||||
this.position.z + z,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Composes a rotor, or an axis and angle in radians, onto the current rotor. */
|
||||
rotate(rotor: ArrayLike<number>): this;
|
||||
rotate(axis: ArrayLike<number>, radians: number): this;
|
||||
rotate(value: ArrayLike<number>, radians?: number) {
|
||||
let rotation: number[];
|
||||
if (radians === undefined) {
|
||||
rotation = vector(value, 4, "rotor");
|
||||
} else {
|
||||
if (!Number.isFinite(radians)) throw new TypeError("radians");
|
||||
const [x, y, z] = vector(value, 3, "axis");
|
||||
const length = Math.hypot(x, y, z);
|
||||
if (!length) throw new RangeError("axis");
|
||||
const sine = Math.sin(radians / 2) / length;
|
||||
rotation = [x * sine, y * sine, z * sine, Math.cos(radians / 2)];
|
||||
}
|
||||
const [ax, ay, az, aw] = this.rotor;
|
||||
const [bx, by, bz, bw] = rotation;
|
||||
const result = [
|
||||
aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz,
|
||||
];
|
||||
const length = Math.hypot(...result);
|
||||
if (!length) throw new RangeError("rotor");
|
||||
return this.setRotor(result.map((lane) => lane / length));
|
||||
}
|
||||
|
||||
/** Rotates around the node's local X axis. */
|
||||
rotateX(radians: number) { return this.rotate([1, 0, 0], radians); }
|
||||
/** Rotates around the node's local Y axis. */
|
||||
rotateY(radians: number) { return this.rotate([0, 1, 0], radians); }
|
||||
/** Rotates around the node's local Z axis. */
|
||||
rotateZ(radians: number) { return this.rotate([0, 0, 1], radians); }
|
||||
|
||||
protected transformChanged() {}
|
||||
|
||||
get enabled() { return this.#row("nodes")[0] !== 0; }
|
||||
set enabled(value: boolean) { this.#row("nodes")[0] = value ? 1 : 0; }
|
||||
|
||||
get parentId() {
|
||||
const pointer = this.#row("nodes")[1];
|
||||
return pointer ? pointer - 1 : null;
|
||||
}
|
||||
|
||||
set parent(value: Node | null) {
|
||||
if (value && value.scene !== this.scene) throw new Error("Nodes must belong to the same Scene");
|
||||
if (value && value.id < 0) throw new Error("Await parent.ready before assigning it");
|
||||
this.#row("nodes")[1] = value ? value.id + 1 : 0;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
await this.scene.releaseNode(this.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type ToneMap = "aces" | "reinhard" | "linear";
|
||||
export class ColorGrading extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; toneMap?: ToneMap; enabled?: boolean } = {}) {
|
||||
super(scene, "colorGrading", options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class DynamicExposure extends PostProcess {
|
||||
constructor(scene: Scene, options: { exposure?: number; enabled?: boolean } = {}) { super(scene, "dynamicExposure", options); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class Edges extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "edges", options); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class FXAA extends PostProcess {
|
||||
constructor(scene: Scene, options: { enabled?: boolean } = {}) { super(scene, "fxaa", options); }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
let nextPostProcess = 1;
|
||||
|
||||
/** Shared graph-membership behavior for the small post-process handles. */
|
||||
export class PostProcess {
|
||||
readonly scene: Scene;
|
||||
readonly id: string;
|
||||
readonly kind: string;
|
||||
options: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
ready: Promise<void>;
|
||||
|
||||
protected constructor(scene: Scene, kind: string, options: Record<string, unknown> = {}) {
|
||||
this.scene = scene;
|
||||
this.kind = kind;
|
||||
this.id = `${kind}-${nextPostProcess++}`;
|
||||
this.enabled = options.enabled !== false;
|
||||
const { enabled: _, ...effectOptions } = options;
|
||||
this.options = effectOptions;
|
||||
this.ready = scene.setPostProcess(this, this.enabled);
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean) {
|
||||
this.enabled = enabled;
|
||||
this.ready = this.scene.setPostProcess(this, enabled);
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
update(options: Record<string, unknown>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
this.ready = this.scene.setPostProcess(this, this.enabled);
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
dispose() { return this.setEnabled(false); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class SSAO extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "ssao", options); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class Silhouette extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "silhouette", options); }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type PickHit = { id: number; distance: number };
|
||||
|
||||
/** Worker-backed broad-phase picking that returns every SAB AABB hit, nearest first. */
|
||||
export class Picking {
|
||||
readonly scene: Scene;
|
||||
readonly ready: Promise<void>;
|
||||
#worker: Worker;
|
||||
#next = 1;
|
||||
#pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this.scene = scene;
|
||||
this.#worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-bvh",
|
||||
});
|
||||
this.#worker.addEventListener("message", ({ data }) => {
|
||||
const pending = this.#pending.get(data.request);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(data.request);
|
||||
pending.resolve(data.hits);
|
||||
});
|
||||
this.#worker.addEventListener("error", () =>
|
||||
this.#fail(new Error("BVH_WORKER_ERROR")),
|
||||
);
|
||||
this.ready = scene.ready.then(() => this.refresh());
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return this.#request("sync", {
|
||||
shares: Object.fromEntries(
|
||||
[
|
||||
"signals",
|
||||
"nodes",
|
||||
"nodePositions",
|
||||
"nodeRotors",
|
||||
"nodeScales",
|
||||
"meshInfo",
|
||||
"bounds",
|
||||
].map((name) => [name, this.scene.array(name).share()]),
|
||||
),
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
async pick(
|
||||
origin: ArrayLike<number>,
|
||||
direction: ArrayLike<number>,
|
||||
): Promise<PickHit[]> {
|
||||
await this.ready;
|
||||
if (origin.length !== 3 || direction.length !== 3)
|
||||
throw new TypeError("Pick rays have three lanes");
|
||||
return this.#request("pick", {
|
||||
origin: Array.from(origin),
|
||||
direction: Array.from(direction),
|
||||
});
|
||||
}
|
||||
|
||||
#request(type: string, payload: object) {
|
||||
const request = this.#next++;
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
this.#pending.set(request, { resolve, reject });
|
||||
this.#worker.postMessage({ type, request, ...payload });
|
||||
});
|
||||
}
|
||||
|
||||
#fail(error: Error) {
|
||||
for (const pending of this.#pending.values()) pending.reject(error);
|
||||
this.#pending.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#fail(new Error("DISPOSED"));
|
||||
this.#worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
export {};
|
||||
|
||||
type SharedRows = {
|
||||
buffer: SharedArrayBuffer;
|
||||
descriptor: { offset: number; rows: number; stride: number; format: string };
|
||||
};
|
||||
type Box = { id: number; min: number[]; max: number[] };
|
||||
type Branch = {
|
||||
min: number[];
|
||||
max: number[];
|
||||
boxes?: Box[];
|
||||
left?: Branch;
|
||||
right?: Branch;
|
||||
};
|
||||
|
||||
let shares: Record<string, SharedRows> = {};
|
||||
let root: Branch | undefined;
|
||||
let builtFrame = -1;
|
||||
|
||||
function view(name: string) {
|
||||
const share = shares[name];
|
||||
if (!share) return undefined;
|
||||
const length = (share.descriptor.rows * share.descriptor.stride) / 4;
|
||||
return share.descriptor.format === "u32"
|
||||
? new Uint32Array(share.buffer, share.descriptor.offset, length)
|
||||
: new Float32Array(share.buffer, share.descriptor.offset, length);
|
||||
}
|
||||
|
||||
function merge(boxes: Box[]) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (const box of boxes)
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
min[lane] = Math.min(min[lane], box.min[lane]);
|
||||
max[lane] = Math.max(max[lane], box.max[lane]);
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function build(boxes: Box[]): Branch | undefined {
|
||||
if (!boxes.length) return undefined;
|
||||
const bounds = merge(boxes);
|
||||
if (boxes.length <= 4) return { ...bounds, boxes };
|
||||
const extents = bounds.max.map((value, lane) => value - bounds.min[lane]);
|
||||
const axis = extents.indexOf(Math.max(...extents));
|
||||
boxes.sort((a, b) => a.min[axis] + a.max[axis] - (b.min[axis] + b.max[axis]));
|
||||
const middle = Math.ceil(boxes.length / 2);
|
||||
return {
|
||||
...bounds,
|
||||
left: build(boxes.slice(0, middle)),
|
||||
right: build(boxes.slice(middle)),
|
||||
};
|
||||
}
|
||||
|
||||
function rotate(rotor: number[], value: number[]) {
|
||||
const [rx, ry, rz, rw] = rotor;
|
||||
const [x, y, z] = value;
|
||||
const tx = 2 * (ry * z - rz * y);
|
||||
const ty = 2 * (rz * x - rx * z);
|
||||
const tz = 2 * (rx * y - ry * x);
|
||||
return [
|
||||
x + rw * tx + ry * tz - rz * ty,
|
||||
y + rw * ty + rz * tx - rx * tz,
|
||||
z + rw * tz + rx * ty - ry * tx,
|
||||
];
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
const bounds = view("bounds");
|
||||
const positions = view("nodePositions");
|
||||
const rotors = view("nodeRotors");
|
||||
const scales = view("nodeScales");
|
||||
const meshes = view("meshInfo");
|
||||
const nodes = view("nodes");
|
||||
if (!bounds || !positions || !rotors || !scales || !meshes || !nodes)
|
||||
return;
|
||||
const count = shares.bounds.descriptor.rows;
|
||||
const boxes: Box[] = [];
|
||||
for (let id = 0; id < count; id++) {
|
||||
if (!nodes[id * 4] || !meshes[id * 4 + 2]) continue;
|
||||
const offset = id * 8;
|
||||
const transform = id * 4;
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
const rotor = [0, 1, 2, 3].map((lane) =>
|
||||
Number(rotors[transform + lane]),
|
||||
);
|
||||
for (let corner = 0; corner < 8; corner++) {
|
||||
const local = [0, 1, 2].map(
|
||||
(lane) =>
|
||||
Number(bounds[offset + (corner & (1 << lane) ? 4 : 0) + lane]) *
|
||||
Number(scales[transform + lane]),
|
||||
);
|
||||
const rotated = rotate(rotor, local);
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
const value = rotated[lane] + Number(positions[transform + lane]);
|
||||
min[lane] = Math.min(min[lane], value);
|
||||
max[lane] = Math.max(max[lane], value);
|
||||
}
|
||||
}
|
||||
if (min.every(Number.isFinite) && max.every(Number.isFinite))
|
||||
boxes.push({ id, min, max });
|
||||
}
|
||||
root = build(boxes);
|
||||
builtFrame = Number(view("signals")?.[1] ?? builtFrame + 1);
|
||||
}
|
||||
|
||||
function intersection(
|
||||
origin: number[],
|
||||
inverse: number[],
|
||||
min: number[],
|
||||
max: number[],
|
||||
) {
|
||||
let near = -Infinity;
|
||||
let far = Infinity;
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
const a = (min[lane] - origin[lane]) * inverse[lane];
|
||||
const b = (max[lane] - origin[lane]) * inverse[lane];
|
||||
near = Math.max(near, Math.min(a, b));
|
||||
far = Math.min(far, Math.max(a, b));
|
||||
}
|
||||
return far >= Math.max(near, 0) ? Math.max(near, 0) : Infinity;
|
||||
}
|
||||
|
||||
function trace(
|
||||
branch: Branch | undefined,
|
||||
origin: number[],
|
||||
inverse: number[],
|
||||
hits: { id: number; distance: number }[],
|
||||
) {
|
||||
if (
|
||||
!branch ||
|
||||
!Number.isFinite(intersection(origin, inverse, branch.min, branch.max))
|
||||
)
|
||||
return;
|
||||
for (const box of branch.boxes ?? []) {
|
||||
const distance = intersection(origin, inverse, box.min, box.max);
|
||||
if (Number.isFinite(distance)) hits.push({ id: box.id, distance });
|
||||
}
|
||||
trace(branch.left, origin, inverse, hits);
|
||||
trace(branch.right, origin, inverse, hits);
|
||||
}
|
||||
|
||||
addEventListener("message", ({ data }) => {
|
||||
if (data.type === "sync") {
|
||||
shares = data.shares;
|
||||
rebuild();
|
||||
postMessage({ type: "synced", request: data.request });
|
||||
return;
|
||||
}
|
||||
if (data.type === "pick") {
|
||||
const frame = Number(view("signals")?.[1] ?? -1);
|
||||
if (frame !== builtFrame) rebuild();
|
||||
const inverse = data.direction.map((lane: number) => 1 / lane);
|
||||
const hits: { id: number; distance: number }[] = [];
|
||||
trace(root, data.origin, inverse, hits);
|
||||
hits.sort((a, b) => a.distance - b.distance);
|
||||
postMessage({ type: "hits", request: data.request, hits });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Node } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
import { Camera, type CameraOptions } from "./Camera";
|
||||
|
||||
export type ArcRotateControls = {
|
||||
element: HTMLElement;
|
||||
pointer?: boolean;
|
||||
controller?: boolean;
|
||||
orbitSpeed?: number;
|
||||
panSpeed?: number;
|
||||
zoomSpeed?: number;
|
||||
};
|
||||
export type ArcRotateCameraOptions = CameraOptions & {
|
||||
target?: Node;
|
||||
targetPosition?: ArrayLike<number>;
|
||||
alpha?: number;
|
||||
beta?: number;
|
||||
radius?: number;
|
||||
controls?: ArcRotateControls;
|
||||
};
|
||||
|
||||
/** Orbit/pan/zoom camera whose controls mutate only its transform and camera SAB rows. */
|
||||
export class ArcRotateCamera extends Camera {
|
||||
#target?: Node;
|
||||
#controls?: ArcRotateControls;
|
||||
#pointer?: { x: number; y: number; button: number };
|
||||
#frame = 0;
|
||||
|
||||
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
||||
super(scene, options);
|
||||
this.#target = options.target;
|
||||
const cameraReady = this.ready;
|
||||
this.ready = cameraReady.then(async () => {
|
||||
if (this.#target) await this.#target.ready;
|
||||
const row = this.cameraRow();
|
||||
row[12] = this.#target ? this.#target.id + 1 : 0;
|
||||
row[13] = options.alpha ?? 0;
|
||||
row[14] = options.beta ?? Math.PI / 3;
|
||||
row[15] = options.radius ?? 5;
|
||||
row.set(Array.from(options.targetPosition ?? [0, 0, 0]), 16);
|
||||
row[19] = 1;
|
||||
this.#updateTransform();
|
||||
if (options.controls) this.attachControls(options.controls);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get alpha() { return this.cameraRow()[13]; }
|
||||
set alpha(value: number) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => { row[13] = value; });
|
||||
}
|
||||
get beta() { return this.cameraRow()[14]; }
|
||||
set beta(value: number) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value));
|
||||
});
|
||||
}
|
||||
get radius() { return this.cameraRow()[15]; }
|
||||
set radius(value: number) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => { row[15] = Math.max(0.01, value); });
|
||||
}
|
||||
|
||||
get target() { return this.#target; }
|
||||
set target(value: Node | undefined) {
|
||||
if (value && (value.scene !== this.scene || value.id < 0)) throw new Error("Target must be a ready Node in this Scene");
|
||||
this.#target = value;
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => { row[12] = value ? value.id + 1 : 0; });
|
||||
}
|
||||
|
||||
attachControls(controls: ArcRotateControls) {
|
||||
this.detachControls();
|
||||
this.#controls = controls;
|
||||
if (controls.pointer !== false) {
|
||||
controls.element.addEventListener("pointerdown", this.#down);
|
||||
controls.element.addEventListener("pointermove", this.#move);
|
||||
controls.element.addEventListener("pointerup", this.#up);
|
||||
controls.element.addEventListener("pointercancel", this.#up);
|
||||
controls.element.addEventListener("contextmenu", this.#contextMenu);
|
||||
controls.element.addEventListener("wheel", this.#wheel, { passive: false });
|
||||
}
|
||||
if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController);
|
||||
return this;
|
||||
}
|
||||
|
||||
detachControls() {
|
||||
const element = this.#controls?.element;
|
||||
if (element) {
|
||||
element.removeEventListener("pointerdown", this.#down);
|
||||
element.removeEventListener("pointermove", this.#move);
|
||||
element.removeEventListener("pointerup", this.#up);
|
||||
element.removeEventListener("pointercancel", this.#up);
|
||||
element.removeEventListener("contextmenu", this.#contextMenu);
|
||||
element.removeEventListener("wheel", this.#wheel);
|
||||
}
|
||||
cancelAnimationFrame(this.#frame);
|
||||
this.#frame = 0;
|
||||
this.#pointer = undefined;
|
||||
this.#controls = undefined;
|
||||
}
|
||||
|
||||
#targetPosition() {
|
||||
const row = this.cameraRow();
|
||||
if (!this.#target) return row.subarray(16, 19);
|
||||
const position = this.#target.position;
|
||||
return [
|
||||
position.x + row[16],
|
||||
position.y + row[17],
|
||||
position.z + row[18],
|
||||
];
|
||||
}
|
||||
|
||||
#updateTransform(change?: () => void) {
|
||||
if (this.id < 0 || this.cameraId < 0) return;
|
||||
this.batchTransformChanges(() => {
|
||||
change?.();
|
||||
const target = this.#targetPosition();
|
||||
const sinBeta = Math.sin(this.beta);
|
||||
this.setPosition([
|
||||
target[0] + this.radius * sinBeta * Math.sin(this.alpha),
|
||||
target[1] + this.radius * Math.cos(this.beta),
|
||||
target[2] + this.radius * sinBeta * Math.cos(this.alpha),
|
||||
]);
|
||||
this.lookAt(target);
|
||||
});
|
||||
}
|
||||
|
||||
#down = (event: PointerEvent) => {
|
||||
event.preventDefault();
|
||||
this.#pointer = { x: event.clientX, y: event.clientY, button: event.button };
|
||||
this.#controls?.element.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
#up = () => { this.#pointer = undefined; };
|
||||
#contextMenu = (event: Event) => event.preventDefault();
|
||||
#move = (event: PointerEvent) => {
|
||||
if (!this.#pointer || !this.#controls) return;
|
||||
const x = event.clientX - this.#pointer.x;
|
||||
const y = event.clientY - this.#pointer.y;
|
||||
this.#pointer.x = event.clientX;
|
||||
this.#pointer.y = event.clientY;
|
||||
if (this.#pointer.button === 2) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
const speed = this.#controls?.panSpeed ?? 0.005;
|
||||
const horizontal = -x * speed;
|
||||
const vertical = y * speed;
|
||||
const sinAlpha = Math.sin(row[13]);
|
||||
const cosAlpha = Math.cos(row[13]);
|
||||
const sinBeta = Math.sin(row[14]);
|
||||
const cosBeta = Math.cos(row[14]);
|
||||
row[16] += horizontal * cosAlpha - vertical * cosBeta * sinAlpha;
|
||||
row[17] += vertical * sinBeta;
|
||||
row[18] -= horizontal * sinAlpha + vertical * cosBeta * cosAlpha;
|
||||
});
|
||||
} else {
|
||||
const speed = this.#controls.orbitSpeed ?? 0.005;
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[13] -= x * speed;
|
||||
row[14] = Math.min(
|
||||
Math.PI - 0.001,
|
||||
Math.max(0.001, row[14] + y * speed),
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
#wheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[15] = Math.max(
|
||||
0.01,
|
||||
row[15] * Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)),
|
||||
);
|
||||
});
|
||||
};
|
||||
#pollController = () => {
|
||||
const pad = navigator.getGamepads?.().find(Boolean);
|
||||
if (pad) {
|
||||
const row = this.cameraRow();
|
||||
this.#updateTransform(() => {
|
||||
row[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03;
|
||||
row[14] = Math.min(
|
||||
Math.PI - 0.001,
|
||||
Math.max(
|
||||
0.001,
|
||||
row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03,
|
||||
),
|
||||
);
|
||||
row[15] = Math.max(
|
||||
0.01,
|
||||
row[15] +
|
||||
((pad.buttons[6]?.value ?? 0) -
|
||||
(pad.buttons[7]?.value ?? 0)) *
|
||||
0.1,
|
||||
);
|
||||
});
|
||||
}
|
||||
this.#frame = requestAnimationFrame(this.#pollController);
|
||||
};
|
||||
|
||||
override async dispose() {
|
||||
this.detachControls();
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
function rotate(rotor: ArrayLike<number>, value: number[]) {
|
||||
const [x, y, z] = value;
|
||||
const tx = 2 * (rotor[1] * z - rotor[2] * y);
|
||||
const ty = 2 * (rotor[2] * x - rotor[0] * z);
|
||||
const tz = 2 * (rotor[0] * y - rotor[1] * x);
|
||||
return [
|
||||
x + rotor[3] * tx + rotor[1] * tz - rotor[2] * ty,
|
||||
y + rotor[3] * ty + rotor[2] * tx - rotor[0] * tz,
|
||||
z + rotor[3] * tz + rotor[0] * ty - rotor[1] * tx,
|
||||
];
|
||||
}
|
||||
|
||||
function dot(left: number[], right: ArrayLike<number>) {
|
||||
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
|
||||
}
|
||||
|
||||
export type CameraOptions = NodeOptions & {
|
||||
fov?: number;
|
||||
near?: number;
|
||||
far?: number;
|
||||
aspect?: number;
|
||||
projection?: "perspective" | "orthographic";
|
||||
orthoSize?: number;
|
||||
focalLength?: number;
|
||||
aperture?: number;
|
||||
focusDistance?: number;
|
||||
sensorWidth?: number;
|
||||
};
|
||||
|
||||
/** Conventional camera data; core only sees another generically allocated shared row. */
|
||||
export class Camera extends Node {
|
||||
cameraId = -1;
|
||||
#cameraDisposed = false;
|
||||
#transformBatchDepth = 0;
|
||||
#transformBatchDirty = false;
|
||||
|
||||
constructor(scene: Scene, options: CameraOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
this.cameraId = await scene.core.allocateObject("cameras");
|
||||
await scene.ensureRows("cameraMatrices", this.cameraId + 1, 80, "f32");
|
||||
const row = scene.array("cameras").row(this.cameraId);
|
||||
row.set([
|
||||
options.fov ?? Math.PI / 3,
|
||||
options.aspect ?? 1,
|
||||
options.near ?? 0.1,
|
||||
options.far ?? 1000,
|
||||
this.id,
|
||||
options.projection === "orthographic" ? 1 : 0,
|
||||
options.orthoSize ?? 10,
|
||||
options.focalLength ?? 50,
|
||||
options.aperture ?? 2.8,
|
||||
options.focusDistance ?? 10,
|
||||
options.sensorWidth ?? 36,
|
||||
1,
|
||||
]);
|
||||
this.refreshMatrix();
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
protected cameraRow() {
|
||||
if (this.cameraId < 0) throw new Error("Await camera.ready before reading or writing it");
|
||||
return this.scene.array("cameras").row(this.cameraId);
|
||||
}
|
||||
|
||||
get fov() { return this.cameraRow()[0]; }
|
||||
set fov(value: number) { this.cameraRow()[0] = value; this.refreshMatrix(); }
|
||||
get aspect() { return this.cameraRow()[1]; }
|
||||
set aspect(value: number) { this.cameraRow()[1] = value; this.refreshMatrix(); }
|
||||
get near() { return this.cameraRow()[2]; }
|
||||
set near(value: number) { this.cameraRow()[2] = value; this.refreshMatrix(); }
|
||||
get far() { return this.cameraRow()[3]; }
|
||||
set far(value: number) { this.cameraRow()[3] = value; this.refreshMatrix(); }
|
||||
get projection() { return this.cameraRow()[5] === 1 ? "orthographic" : "perspective"; }
|
||||
set projection(value: "perspective" | "orthographic") {
|
||||
this.cameraRow()[5] = value === "orthographic" ? 1 : 0;
|
||||
this.refreshMatrix();
|
||||
}
|
||||
get orthoSize() { return this.cameraRow()[6]; }
|
||||
set orthoSize(value: number) { this.cameraRow()[6] = value; this.refreshMatrix(); }
|
||||
get focalLength() { return this.cameraRow()[7]; }
|
||||
set focalLength(value: number) { this.cameraRow()[7] = value; }
|
||||
get aperture() { return this.cameraRow()[8]; }
|
||||
set aperture(value: number) { this.cameraRow()[8] = value; }
|
||||
get focusDistance() { return this.cameraRow()[9]; }
|
||||
set focusDistance(value: number) { this.cameraRow()[9] = value; }
|
||||
get sensorWidth() { return this.cameraRow()[10]; }
|
||||
set sensorWidth(value: number) { this.cameraRow()[10] = value; }
|
||||
|
||||
lookAt(target: ArrayLike<number>) {
|
||||
if (target.length !== 3) throw new RangeError("camera target");
|
||||
const position = this.position;
|
||||
const x = target[0] - position.x;
|
||||
const y = target[1] - position.y;
|
||||
const z = target[2] - position.z;
|
||||
const length = Math.hypot(x, y, z) || 1;
|
||||
const direction = [x / length, y / length, z / length];
|
||||
const horizontal = Math.hypot(direction[0], direction[2]);
|
||||
const yaw = horizontal > 0.000001
|
||||
? Math.atan2(-direction[0], -direction[2])
|
||||
: 0;
|
||||
const pitch = Math.atan2(direction[1], horizontal);
|
||||
const sy = Math.sin(yaw / 2), cy = Math.cos(yaw / 2);
|
||||
const sx = Math.sin(pitch / 2), cx = Math.cos(pitch / 2);
|
||||
this.setRotor([sx * cy, cx * sy, -sx * sy, cx * cy]);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override transformChanged() {
|
||||
if (this.#transformBatchDepth) {
|
||||
this.#transformBatchDirty = true;
|
||||
return;
|
||||
}
|
||||
this.refreshMatrix();
|
||||
}
|
||||
|
||||
/** Publishes one final camera matrix after a synchronous group of transform writes. */
|
||||
protected batchTransformChanges<T>(operation: () => T) {
|
||||
return this.scene.batchWrites(() => {
|
||||
this.#transformBatchDepth++;
|
||||
try {
|
||||
return operation();
|
||||
} finally {
|
||||
this.#transformBatchDepth--;
|
||||
if (!this.#transformBatchDepth && this.#transformBatchDirty) {
|
||||
this.#transformBatchDirty = false;
|
||||
this.refreshMatrix();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected refreshMatrix() {
|
||||
if (this.id < 0 || this.cameraId < 0) return;
|
||||
const camera = this.cameraRow();
|
||||
const position = this.position;
|
||||
const rotor = this.rotor;
|
||||
const right = rotate(rotor, [1, 0, 0]);
|
||||
const up = rotate(rotor, [0, 1, 0]);
|
||||
const forward = rotate(rotor, [0, 0, 1]);
|
||||
let rows: number[][];
|
||||
if (camera[5] === 1) {
|
||||
const size = Math.max(camera[6], 0.0001);
|
||||
const x = 2 / (size * camera[1]);
|
||||
const y = 2 / size;
|
||||
const z = -1 / camera[3];
|
||||
rows = [
|
||||
[...right.map((value) => value * x), -dot(right, position) * x],
|
||||
[...up.map((value) => value * y), -dot(up, position) * y],
|
||||
[...forward.map((value) => value * z), -dot(forward, position) * z],
|
||||
[0, 0, 0, 1],
|
||||
];
|
||||
} else {
|
||||
const focal = 1 / Math.tan(camera[0] * 0.5);
|
||||
const x = focal / camera[1];
|
||||
const z = -camera[3] / (camera[3] - camera[2]);
|
||||
const translation = (-camera[2] * camera[3]) / (camera[3] - camera[2]);
|
||||
rows = [
|
||||
[...right.map((value) => value * x), -dot(right, position) * x],
|
||||
[...up.map((value) => value * focal), -dot(up, position) * focal],
|
||||
[
|
||||
...forward.map((value) => value * z),
|
||||
-dot(forward, position) * z + translation,
|
||||
],
|
||||
[...forward.map((value) => -value), dot(forward, position)],
|
||||
];
|
||||
}
|
||||
this.scene
|
||||
.array("cameraMatrices")
|
||||
.row(this.cameraId)
|
||||
.set([...rows.flat(), ...position, camera[11]]);
|
||||
}
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.#cameraDisposed) return;
|
||||
this.#cameraDisposed = true;
|
||||
await this.scene.core.deleteObject("cameras", this.cameraId);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Node } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
import { Camera, type CameraOptions } from "./Camera";
|
||||
|
||||
export type FollowCameraOptions = CameraOptions & {
|
||||
target: Node;
|
||||
distance?: number;
|
||||
height?: number;
|
||||
smoothing?: number;
|
||||
running?: boolean;
|
||||
};
|
||||
|
||||
/** Third-person camera that follows a target Node by direct shared-row reads and writes. */
|
||||
export class FollowCamera extends Camera {
|
||||
#target: Node;
|
||||
#frame = 0;
|
||||
#smoothing: number;
|
||||
|
||||
constructor(scene: Scene, options: FollowCameraOptions) {
|
||||
if (!options?.target) throw new TypeError("FollowCamera target is required");
|
||||
super(scene, options);
|
||||
this.#target = options.target;
|
||||
this.#smoothing = options.smoothing ?? 0.12;
|
||||
const cameraReady = this.ready;
|
||||
this.ready = cameraReady.then(async () => {
|
||||
await this.#target.ready;
|
||||
if (this.#target.scene !== scene) throw new Error("Target must belong to this Scene");
|
||||
const row = this.cameraRow();
|
||||
row[12] = this.#target.id + 1;
|
||||
row[15] = options.distance ?? 6;
|
||||
row[16] = options.height ?? 2;
|
||||
row[19] = 3;
|
||||
this.#snap();
|
||||
if (options.running !== false) this.start();
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get target() { return this.#target; }
|
||||
set target(value: Node) {
|
||||
if (value.scene !== this.scene || value.id < 0) throw new Error("Target must be a ready Node in this Scene");
|
||||
this.#target = value;
|
||||
this.cameraRow()[12] = value.id + 1;
|
||||
}
|
||||
get distance() { return this.cameraRow()[15]; }
|
||||
set distance(value: number) { this.cameraRow()[15] = Math.max(0, value); }
|
||||
get height() { return this.cameraRow()[16]; }
|
||||
set height(value: number) { this.cameraRow()[16] = value; }
|
||||
get smoothing() { return this.#smoothing; }
|
||||
set smoothing(value: number) { this.#smoothing = Math.min(1, Math.max(0, value)); }
|
||||
|
||||
start() {
|
||||
if (!this.#frame) this.#frame = requestAnimationFrame(this.#follow);
|
||||
return this;
|
||||
}
|
||||
|
||||
stop() {
|
||||
cancelAnimationFrame(this.#frame);
|
||||
this.#frame = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
#snap() {
|
||||
const target = this.#target.position;
|
||||
this.setPosition([target.x, target.y + this.height, target.z + this.distance]);
|
||||
this.lookAt(target);
|
||||
}
|
||||
|
||||
#follow = () => {
|
||||
const target = this.#target.position;
|
||||
const position = this.position;
|
||||
this.setPosition([
|
||||
position.x + (target.x - position.x) * this.#smoothing,
|
||||
position.y + (target.y + this.height - position.y) * this.#smoothing,
|
||||
position.z + (target.z + this.distance - position.z) * this.#smoothing,
|
||||
]);
|
||||
this.lookAt(target);
|
||||
this.#frame = requestAnimationFrame(this.#follow);
|
||||
};
|
||||
|
||||
override async dispose() {
|
||||
this.stop();
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Scene } from "../Scene";
|
||||
import { Camera, type CameraOptions } from "./Camera";
|
||||
|
||||
export type FreeCameraControls = {
|
||||
element: HTMLElement;
|
||||
keyboard?: boolean;
|
||||
pointer?: boolean;
|
||||
controller?: boolean;
|
||||
speed?: number;
|
||||
sensitivity?: number;
|
||||
};
|
||||
export type FreeCameraOptions = CameraOptions & { controls?: FreeCameraControls };
|
||||
|
||||
/** Spectator-style WASD/mouse/gamepad camera backed entirely by shared camera and transform rows. */
|
||||
export class FreeCamera extends Camera {
|
||||
#controls?: FreeCameraControls;
|
||||
#keys = new Set<string>();
|
||||
#frame = 0;
|
||||
#last = 0;
|
||||
|
||||
constructor(scene: Scene, options: FreeCameraOptions = {}) {
|
||||
super(scene, options);
|
||||
const cameraReady = this.ready;
|
||||
this.ready = cameraReady.then(() => {
|
||||
this.cameraRow()[19] = 2;
|
||||
if (options.controls) this.attachControls(options.controls);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
attachControls(controls: FreeCameraControls) {
|
||||
this.detachControls();
|
||||
this.#controls = controls;
|
||||
if (controls.keyboard !== false) {
|
||||
addEventListener("keydown", this.#keyDown);
|
||||
addEventListener("keyup", this.#keyUp);
|
||||
}
|
||||
if (controls.pointer !== false) {
|
||||
controls.element.addEventListener("click", this.#lock);
|
||||
addEventListener("mousemove", this.#mouse);
|
||||
}
|
||||
this.#last = performance.now();
|
||||
this.#frame = requestAnimationFrame(this.#update);
|
||||
return this;
|
||||
}
|
||||
|
||||
detachControls() {
|
||||
const element = this.#controls?.element;
|
||||
if (element) element.removeEventListener("click", this.#lock);
|
||||
removeEventListener("keydown", this.#keyDown);
|
||||
removeEventListener("keyup", this.#keyUp);
|
||||
removeEventListener("mousemove", this.#mouse);
|
||||
cancelAnimationFrame(this.#frame);
|
||||
this.#frame = 0;
|
||||
this.#keys.clear();
|
||||
this.#controls = undefined;
|
||||
}
|
||||
|
||||
#keyDown = (event: KeyboardEvent) => this.#keys.add(event.code);
|
||||
#keyUp = (event: KeyboardEvent) => this.#keys.delete(event.code);
|
||||
#lock = () => this.#controls?.element.requestPointerLock?.();
|
||||
#mouse = (event: MouseEvent) => {
|
||||
if (!this.#controls || document.pointerLockElement !== this.#controls.element) return;
|
||||
const sensitivity = this.#controls.sensitivity ?? 0.002;
|
||||
this.cameraRow()[13] -= event.movementX * sensitivity;
|
||||
this.cameraRow()[14] = Math.min(1.55, Math.max(-1.55, this.cameraRow()[14] - event.movementY * sensitivity));
|
||||
this.#writeRotor();
|
||||
};
|
||||
|
||||
#writeRotor() {
|
||||
const yaw = this.cameraRow()[13];
|
||||
const pitch = this.cameraRow()[14];
|
||||
const sy = Math.sin(yaw / 2), cy = Math.cos(yaw / 2);
|
||||
const sx = Math.sin(pitch / 2), cx = Math.cos(pitch / 2);
|
||||
this.setRotor([sx * cy, cx * sy, -sx * sy, cx * cy]);
|
||||
}
|
||||
|
||||
#update = (time: number) => {
|
||||
if (!this.#controls) return;
|
||||
const delta = Math.min(0.1, (time - this.#last) / 1000);
|
||||
this.#last = time;
|
||||
let x = Number(this.#keys.has("KeyD")) - Number(this.#keys.has("KeyA"));
|
||||
let y = Number(this.#keys.has("Space")) - Number(this.#keys.has("ControlLeft"));
|
||||
let z = Number(this.#keys.has("KeyW")) - Number(this.#keys.has("KeyS"));
|
||||
if (this.#controls.controller) {
|
||||
const pad = navigator.getGamepads?.().find(Boolean);
|
||||
if (pad) { x += pad.axes[0] ?? 0; y -= pad.axes[3] ?? 0; z -= pad.axes[1] ?? 0; }
|
||||
}
|
||||
const speed = (this.#controls.speed ?? 4) * delta;
|
||||
const yaw = this.cameraRow()[13];
|
||||
const position = this.position;
|
||||
this.setPosition([
|
||||
position.x + (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed,
|
||||
position.y + y * speed,
|
||||
position.z + (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed,
|
||||
]);
|
||||
this.#frame = requestAnimationFrame(this.#update);
|
||||
};
|
||||
|
||||
override async dispose() {
|
||||
this.detachControls();
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Mesh } from "../Mesh";
|
||||
import type { Scene } from "../Scene";
|
||||
import { PBRMaterial } from "../materials/PBRMaterial";
|
||||
import { Texture } from "../materials/Texture";
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let nextRequest = 1;
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
function importer() {
|
||||
if (worker) return worker;
|
||||
worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-importer",
|
||||
});
|
||||
worker.addEventListener("message", ({ data }) => {
|
||||
const request = pending.get(data.request);
|
||||
if (!request) return;
|
||||
pending.delete(data.request);
|
||||
if (data.error) request.reject(new Error(data.error));
|
||||
else request.resolve(data.result);
|
||||
});
|
||||
worker.addEventListener("error", () => {
|
||||
for (const request of pending.values())
|
||||
request.reject(new Error("IMPORT_WORKER_ERROR"));
|
||||
pending.clear();
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
|
||||
/** Imports glTF/GLB off-thread, then hydrates conventional handles backed by Scene SAB rows. */
|
||||
export async function importGltf(scene: Scene, url: string | URL) {
|
||||
await scene.ready;
|
||||
const request = nextRequest++;
|
||||
const result = await new Promise<any>((resolve, reject) => {
|
||||
pending.set(request, { resolve, reject });
|
||||
importer().postMessage({ request, url: String(url) });
|
||||
});
|
||||
await scene.reserve({
|
||||
nodes: result.primitives.length,
|
||||
materials: result.materials.length,
|
||||
});
|
||||
let textures: Texture[] = [];
|
||||
const imported = await scene.batchGraphUpdates(async () => {
|
||||
const srgb = new Set<number>();
|
||||
for (const material of result.materials) {
|
||||
if (material.baseColorTexture >= 0) srgb.add(material.baseColorTexture);
|
||||
if (material.emissiveTexture >= 0) srgb.add(material.emissiveTexture);
|
||||
}
|
||||
textures = result.textures.map(
|
||||
({ image }: { image: ImageBitmap }, index: number) =>
|
||||
new Texture(scene, {
|
||||
source: image,
|
||||
format: srgb.has(index) ? "rgba8unorm-srgb" : "rgba8unorm",
|
||||
}),
|
||||
);
|
||||
await Promise.all(textures.map((texture: Texture) => texture.ready));
|
||||
const materials = result.materials.map(
|
||||
(options: any) =>
|
||||
new PBRMaterial(scene, {
|
||||
...options,
|
||||
baseColorTexture: textures[options.baseColorTexture],
|
||||
metallicRoughnessTexture:
|
||||
textures[options.metallicRoughnessTexture],
|
||||
normalTexture: textures[options.normalTexture],
|
||||
emissiveTexture: textures[options.emissiveTexture],
|
||||
}),
|
||||
);
|
||||
await Promise.all(materials.map((material: PBRMaterial) => material.ready));
|
||||
const meshes: Mesh[] = result.primitives.map(
|
||||
(primitive: any) =>
|
||||
new Mesh(scene, {
|
||||
position: primitive.position,
|
||||
rotor: primitive.rotor,
|
||||
scale: primitive.scale,
|
||||
material: materials[primitive.material],
|
||||
vertexData: {
|
||||
positions: primitive.positions,
|
||||
indices: primitive.indices,
|
||||
...(primitive.normals ? { normals: primitive.normals } : {}),
|
||||
...(primitive.tangents ? { tangents: primitive.tangents } : {}),
|
||||
...(primitive.uvs ? { uvs: primitive.uvs } : {}),
|
||||
...(primitive.colors ? { colors: primitive.colors } : {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await Promise.all(meshes.map((mesh) => mesh.ready));
|
||||
return meshes;
|
||||
});
|
||||
await Promise.all(
|
||||
result.textures.map((_: unknown, index: number) =>
|
||||
scene.core.deleteTexture(textures[index].resource),
|
||||
),
|
||||
);
|
||||
return imported;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
export {};
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const widths: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
|
||||
const sizes: Record<number, number> = {
|
||||
5120: 1,
|
||||
5121: 1,
|
||||
5122: 2,
|
||||
5123: 2,
|
||||
5125: 4,
|
||||
5126: 4,
|
||||
};
|
||||
const arrays: Record<number, any> = {
|
||||
5120: Int8Array,
|
||||
5121: Uint8Array,
|
||||
5122: Int16Array,
|
||||
5123: Uint16Array,
|
||||
5125: Uint32Array,
|
||||
5126: Float32Array,
|
||||
};
|
||||
|
||||
function component(view: DataView, offset: number, type: number) {
|
||||
if (type === 5120) return view.getInt8(offset);
|
||||
if (type === 5121) return view.getUint8(offset);
|
||||
if (type === 5122) return view.getInt16(offset, true);
|
||||
if (type === 5123) return view.getUint16(offset, true);
|
||||
if (type === 5125) return view.getUint32(offset, true);
|
||||
if (type === 5126) return view.getFloat32(offset, true);
|
||||
throw new Error("GLTF_COMPONENT");
|
||||
}
|
||||
|
||||
function rotate(rotor: number[], value: number[]) {
|
||||
const [rx, ry, rz, rw] = rotor;
|
||||
const [x, y, z] = value;
|
||||
const tx = 2 * (ry * z - rz * y);
|
||||
const ty = 2 * (rz * x - rx * z);
|
||||
const tz = 2 * (rx * y - ry * x);
|
||||
return [
|
||||
x + rw * tx + ry * tz - rz * ty,
|
||||
y + rw * ty + rz * tx - rx * tz,
|
||||
z + rw * tz + rx * ty - ry * tx,
|
||||
];
|
||||
}
|
||||
|
||||
function multiply(a: number[], b: number[]) {
|
||||
return [
|
||||
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||
];
|
||||
}
|
||||
|
||||
function decompose(matrix: number[]) {
|
||||
const scale = [
|
||||
Math.hypot(matrix[0], matrix[1], matrix[2]),
|
||||
Math.hypot(matrix[4], matrix[5], matrix[6]),
|
||||
Math.hypot(matrix[8], matrix[9], matrix[10]),
|
||||
];
|
||||
const [sx, sy, sz] = scale;
|
||||
const [m00, m01, m02] = [matrix[0] / sx, matrix[4] / sy, matrix[8] / sz];
|
||||
const [m10, m11, m12] = [matrix[1] / sx, matrix[5] / sy, matrix[9] / sz];
|
||||
const [m20, m21, m22] = [matrix[2] / sx, matrix[6] / sy, matrix[10] / sz];
|
||||
let rotor: number[];
|
||||
if (m00 + m11 + m22 > 0) {
|
||||
const s = Math.sqrt(1 + m00 + m11 + m22) * 2;
|
||||
rotor = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
|
||||
} else if (m00 > m11 && m00 > m22) {
|
||||
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
||||
rotor = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
|
||||
} else if (m11 > m22) {
|
||||
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
||||
rotor = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
|
||||
} else {
|
||||
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
||||
rotor = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
|
||||
}
|
||||
return { position: matrix.slice(12, 15), rotor, scale };
|
||||
}
|
||||
|
||||
function transform(node: any) {
|
||||
return node.matrix
|
||||
? decompose(node.matrix)
|
||||
: {
|
||||
position: node.translation ?? [0, 0, 0],
|
||||
rotor: node.rotation ?? [0, 0, 0, 1],
|
||||
scale: node.scale ?? [1, 1, 1],
|
||||
};
|
||||
}
|
||||
|
||||
function compose(parent: any, local: any) {
|
||||
const position = rotate(
|
||||
parent.rotor,
|
||||
local.position.map(
|
||||
(value: number, lane: number) => value * parent.scale[lane],
|
||||
),
|
||||
);
|
||||
return {
|
||||
position: position.map((value, lane) => value + parent.position[lane]),
|
||||
rotor: multiply(parent.rotor, local.rotor),
|
||||
scale: local.scale.map(
|
||||
(value: number, lane: number) => value * parent.scale[lane],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parse(bytes: Uint8Array) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (view.getUint32(0, true) !== 0x46546c67)
|
||||
return {
|
||||
document: JSON.parse(decoder.decode(bytes)),
|
||||
binary: undefined as Uint8Array | undefined,
|
||||
};
|
||||
let offset = 12;
|
||||
let document: any;
|
||||
let binary: Uint8Array | undefined;
|
||||
while (offset < bytes.length) {
|
||||
const length = view.getUint32(offset, true);
|
||||
const type = view.getUint32(offset + 4, true);
|
||||
const chunk = bytes.subarray(offset + 8, offset + 8 + length);
|
||||
if (type === 0x4e4f534a)
|
||||
document = JSON.parse(decoder.decode(chunk).replace(/\0+$/u, ""));
|
||||
if (type === 0x004e4942) binary = chunk;
|
||||
offset += 8 + length;
|
||||
}
|
||||
if (!document) throw new Error("GLTF_JSON");
|
||||
return { document, binary };
|
||||
}
|
||||
|
||||
async function load(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||
const { document, binary } = parse(
|
||||
new Uint8Array(await response.arrayBuffer()),
|
||||
);
|
||||
const buffers = await Promise.all(
|
||||
(document.buffers ?? []).map(async (buffer: any, index: number) => {
|
||||
if (buffer.uri === undefined) {
|
||||
if (index || !binary) throw new Error("GLTF_BUFFER");
|
||||
return binary;
|
||||
}
|
||||
const result = await fetch(new URL(buffer.uri, url));
|
||||
if (!result.ok) throw new Error(`HTTP_${result.status}`);
|
||||
return new Uint8Array(await result.arrayBuffer());
|
||||
}),
|
||||
);
|
||||
const imageBlobs = await Promise.all(
|
||||
(document.images ?? []).map(async (image: any) => {
|
||||
if (image.bufferView !== undefined) {
|
||||
const view = document.bufferViews[image.bufferView];
|
||||
const bytes = buffers[view.buffer];
|
||||
const start = view.byteOffset ?? 0;
|
||||
return new Blob([bytes.slice(start, start + view.byteLength)], {
|
||||
type: image.mimeType,
|
||||
});
|
||||
}
|
||||
const result = await fetch(new URL(image.uri, url));
|
||||
if (!result.ok) throw new Error(`HTTP_${result.status}`);
|
||||
return result.blob();
|
||||
}),
|
||||
);
|
||||
const textures = await Promise.all(
|
||||
(document.textures ?? []).map(async (texture: any) => ({
|
||||
image: await createImageBitmap(imageBlobs[texture.source]),
|
||||
})),
|
||||
);
|
||||
|
||||
const accessor = (id: number, integer = false) => {
|
||||
const source = document.accessors[id];
|
||||
const width = widths[source.type];
|
||||
const size = sizes[source.componentType];
|
||||
const bufferView = document.bufferViews[source.bufferView];
|
||||
const bytes = buffers[bufferView.buffer];
|
||||
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
|
||||
const stride = bufferView.byteStride ?? width * size;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const values = integer
|
||||
? new Uint32Array(source.count * width)
|
||||
: new Float32Array(source.count * width);
|
||||
if (
|
||||
!source.normalized &&
|
||||
stride === width * size &&
|
||||
(bytes.byteOffset + start) % size === 0
|
||||
) {
|
||||
const packed = new arrays[source.componentType](
|
||||
bytes.buffer,
|
||||
bytes.byteOffset + start,
|
||||
source.count * width,
|
||||
);
|
||||
values.set(packed);
|
||||
return values;
|
||||
}
|
||||
for (let item = 0; item < source.count; item++)
|
||||
for (let lane = 0; lane < width; lane++) {
|
||||
let value = component(
|
||||
view,
|
||||
start + item * stride + lane * size,
|
||||
source.componentType,
|
||||
);
|
||||
if (!integer && source.normalized) {
|
||||
const maximum =
|
||||
source.componentType === 5121
|
||||
? 255
|
||||
: source.componentType === 5123
|
||||
? 65535
|
||||
: 1;
|
||||
value /= maximum;
|
||||
}
|
||||
values[item * width + lane] = value;
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const materials = (document.materials ?? []).map((material: any) => {
|
||||
const pbr = material.pbrMetallicRoughness ?? {};
|
||||
return {
|
||||
baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1],
|
||||
metallic: pbr.metallicFactor ?? 0,
|
||||
roughness: pbr.roughnessFactor ?? 0.7,
|
||||
emissive: material.emissiveFactor ?? [0, 0, 0],
|
||||
alphaCutoff: material.alphaCutoff ?? 0.5,
|
||||
baseColorTexture: pbr.baseColorTexture?.index ?? -1,
|
||||
metallicRoughnessTexture: pbr.metallicRoughnessTexture?.index ?? -1,
|
||||
normalTexture: material.normalTexture?.index ?? -1,
|
||||
emissiveTexture: material.emissiveTexture?.index ?? -1,
|
||||
};
|
||||
});
|
||||
const primitives: any[] = [];
|
||||
const emitMesh = (meshId: number, transform: any) => {
|
||||
const mesh = document.meshes?.[meshId];
|
||||
for (const primitive of mesh?.primitives ?? []) {
|
||||
if (
|
||||
(primitive.mode ?? 4) !== 4 ||
|
||||
primitive.attributes.POSITION === undefined
|
||||
)
|
||||
continue;
|
||||
const positions = accessor(primitive.attributes.POSITION);
|
||||
const indices =
|
||||
primitive.indices === undefined
|
||||
? Uint32Array.from(
|
||||
{ length: positions.length / 3 },
|
||||
(_, index) => index,
|
||||
)
|
||||
: accessor(primitive.indices, true);
|
||||
primitives.push({
|
||||
positions,
|
||||
indices,
|
||||
...(primitive.attributes.NORMAL === undefined
|
||||
? {}
|
||||
: { normals: accessor(primitive.attributes.NORMAL) }),
|
||||
...(primitive.attributes.TANGENT === undefined
|
||||
? {}
|
||||
: { tangents: accessor(primitive.attributes.TANGENT) }),
|
||||
...(primitive.attributes.TEXCOORD_0 === undefined
|
||||
? {}
|
||||
: { uvs: accessor(primitive.attributes.TEXCOORD_0) }),
|
||||
...(primitive.attributes.COLOR_0 === undefined
|
||||
? {}
|
||||
: { colors: accessor(primitive.attributes.COLOR_0) }),
|
||||
material: primitive.material ?? -1,
|
||||
...transform,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nodes = document.nodes ?? [];
|
||||
const scene = document.scenes?.[document.scene ?? 0];
|
||||
const children = new Set(nodes.flatMap((node: any) => node.children ?? []));
|
||||
const roots =
|
||||
scene?.nodes ??
|
||||
nodes
|
||||
.map((_: any, id: number) => id)
|
||||
.filter((id: number) => !children.has(id));
|
||||
const visit = (
|
||||
id: number,
|
||||
parent = {
|
||||
position: [0, 0, 0],
|
||||
rotor: [0, 0, 0, 1],
|
||||
scale: [1, 1, 1],
|
||||
},
|
||||
) => {
|
||||
const node = nodes[id] ?? {};
|
||||
const world = compose(parent, transform(node));
|
||||
if (node.mesh !== undefined) emitMesh(node.mesh, world);
|
||||
for (const child of node.children ?? []) visit(child, world);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
if (!nodes.length)
|
||||
for (let id = 0; id < (document.meshes ?? []).length; id++)
|
||||
emitMesh(id, {});
|
||||
return { materials, primitives, textures };
|
||||
}
|
||||
|
||||
addEventListener("message", async ({ data }) => {
|
||||
try {
|
||||
const result = await load(data.url);
|
||||
const transfers = result.primitives.flatMap((primitive: any) =>
|
||||
["positions", "indices", "normals", "tangents", "uvs", "colors"]
|
||||
.map((name) => primitive[name]?.buffer)
|
||||
.filter(Boolean),
|
||||
);
|
||||
transfers.push(...result.textures.map((texture: any) => texture.image));
|
||||
(postMessage as any)({ request: data.request, result }, transfers);
|
||||
} catch (error) {
|
||||
postMessage({
|
||||
request: data.request,
|
||||
error: error instanceof Error ? error.message : "GLTF_IMPORT",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export * from "./Scene";
|
||||
export * from "./ComputePass";
|
||||
export * from "./Node";
|
||||
export * from "./Mesh";
|
||||
export * from "./camera/Camera";
|
||||
export * from "./camera/ArcRotateCamera";
|
||||
export * from "./camera/FreeCamera";
|
||||
export * from "./camera/FollowCamera";
|
||||
|
||||
export * from "./lights/PointLight";
|
||||
export * from "./lights/RectAreaLight";
|
||||
export * from "./lights/SpotLight";
|
||||
export * from "./lights/DirectionalLight";
|
||||
export * from "./lights/AmbientLight";
|
||||
|
||||
export * from "./materials/Texture";
|
||||
export * from "./materials/PBRMaterial";
|
||||
export * from "./materials/ShaderMaterial";
|
||||
|
||||
export * from "./bvh/picking";
|
||||
export * from "./importers/gltf";
|
||||
|
||||
export * from "./PostProcesses/PostProcess";
|
||||
export * from "./PostProcesses/SSAO";
|
||||
export * from "./PostProcesses/FXAA";
|
||||
export * from "./PostProcesses/ColorGrading";
|
||||
export * from "./PostProcesses/DynamicExposure";
|
||||
export * from "./PostProcesses/Silhouette";
|
||||
export * from "./PostProcesses/Edges";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type AmbientLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number };
|
||||
|
||||
export class AmbientLight extends Node {
|
||||
constructor(scene: Scene, options: AmbientLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("ambientLights", this.id + 1, 16, "f32");
|
||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 0.1]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get intensity() { return this.scene.array("ambientLights").row(this.id)[3]; }
|
||||
set intensity(value: number) { this.scene.array("ambientLights").row(this.id)[3] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("ambientLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type DirectionalLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number };
|
||||
|
||||
export class DirectionalLight extends Node {
|
||||
constructor(scene: Scene, options: DirectionalLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
|
||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get intensity() { return this.scene.array("directionalLights").row(this.id)[3]; }
|
||||
set intensity(value: number) { this.scene.array("directionalLights").row(this.id)[3] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("directionalLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type PointLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number; range?: number };
|
||||
|
||||
export class PointLight extends Node {
|
||||
constructor(scene: Scene, options: PointLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("pointLights", this.id + 1, 32, "f32");
|
||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, 1, 0, 0]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get color() { return this.scene.array("pointLights").row(this.id).subarray(0, 3); }
|
||||
set color(value: ArrayLike<number>) { this.scene.array("pointLights").row(this.id).set(value, 0); }
|
||||
get intensity() { return this.scene.array("pointLights").row(this.id)[3]; }
|
||||
set intensity(value: number) { this.scene.array("pointLights").row(this.id)[3] = value; }
|
||||
get range() { return this.scene.array("pointLights").row(this.id)[4]; }
|
||||
set range(value: number) { this.scene.array("pointLights").row(this.id)[4] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("pointLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type RectAreaLightOptions = NodeOptions & {
|
||||
color?: ArrayLike<number>;
|
||||
intensity?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/** Rectangular emitter data for the default clustered forward graph's LTC path. */
|
||||
export class RectAreaLight extends Node {
|
||||
readonly technique = "ltc";
|
||||
|
||||
constructor(scene: Scene, options: RectAreaLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("rectAreaLights", this.id + 1, 48, "f32");
|
||||
array.row(this.id).set([
|
||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||
options.width ?? 1, options.height ?? 1, 1, 0,
|
||||
]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get width() { return this.scene.array("rectAreaLights").row(this.id)[4]; }
|
||||
set width(value: number) { this.scene.array("rectAreaLights").row(this.id)[4] = value; }
|
||||
get height() { return this.scene.array("rectAreaLights").row(this.id)[5]; }
|
||||
set height(value: number) { this.scene.array("rectAreaLights").row(this.id)[5] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("rectAreaLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type SpotLightOptions = NodeOptions & {
|
||||
color?: ArrayLike<number>;
|
||||
intensity?: number;
|
||||
range?: number;
|
||||
innerAngle?: number;
|
||||
outerAngle?: number;
|
||||
};
|
||||
|
||||
export class SpotLight extends Node {
|
||||
constructor(scene: Scene, options: SpotLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("spotLights", this.id + 1, 32, "f32");
|
||||
array.row(this.id).set([
|
||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
||||
]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get innerAngle() { return this.scene.array("spotLights").row(this.id)[5]; }
|
||||
set innerAngle(value: number) { this.scene.array("spotLights").row(this.id)[5] = value; }
|
||||
get outerAngle() { return this.scene.array("spotLights").row(this.id)[6]; }
|
||||
set outerAngle(value: number) { this.scene.array("spotLights").row(this.id)[6] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("spotLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Scene } from "../Scene";
|
||||
import type { Texture } from "./Texture";
|
||||
|
||||
export type PBRMaterialOptions = {
|
||||
baseColor?: ArrayLike<number>;
|
||||
metallic?: number;
|
||||
roughness?: number;
|
||||
emissive?: ArrayLike<number>;
|
||||
normalScale?: number;
|
||||
alphaCutoff?: number;
|
||||
baseColorTexture?: Texture;
|
||||
metallicRoughnessTexture?: Texture;
|
||||
normalTexture?: Texture;
|
||||
emissiveTexture?: Texture;
|
||||
};
|
||||
|
||||
/** Conventional PBR values and texture IDs stored in two shared SOA rows. */
|
||||
export class PBRMaterial {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
readonly ready: Promise<this>;
|
||||
#disposed = false;
|
||||
|
||||
constructor(scene: Scene, options: PBRMaterialOptions = {}) {
|
||||
this.scene = scene;
|
||||
const textures = [
|
||||
options.baseColorTexture,
|
||||
options.metallicRoughnessTexture,
|
||||
options.normalTexture,
|
||||
options.emissiveTexture,
|
||||
].filter((texture): texture is Texture => texture !== undefined);
|
||||
this.ready = Promise.all(textures.map((texture) => texture.ready)).then(async () => {
|
||||
const id = await scene.allocateMaterial();
|
||||
this.id = id;
|
||||
const color = Array.from(options.baseColor ?? [1, 1, 1, 1]);
|
||||
const emissive = Array.from(options.emissive ?? [0, 0, 0]);
|
||||
if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors");
|
||||
scene.array("materials").write(id, [
|
||||
...color,
|
||||
options.metallic ?? 0,
|
||||
options.roughness ?? 0.7,
|
||||
...emissive,
|
||||
options.normalScale ?? 1,
|
||||
options.alphaCutoff ?? 0.5,
|
||||
0,
|
||||
]);
|
||||
scene.array("materialTextures").write(id, [
|
||||
(options.baseColorTexture?.id ?? -1) + 1,
|
||||
(options.metallicRoughnessTexture?.id ?? -1) + 1,
|
||||
(options.normalTexture?.id ?? -1) + 1,
|
||||
(options.emissiveTexture?.id ?? -1) + 1,
|
||||
0, 0, 0, 0,
|
||||
]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
#values() {
|
||||
if (this.id < 0) throw new Error("Await material.ready before reading or writing it");
|
||||
return this.scene.array("materials").row(this.id);
|
||||
}
|
||||
|
||||
get baseColor() { return this.#values().subarray(0, 4); }
|
||||
set baseColor(value: ArrayLike<number>) {
|
||||
if (value.length !== 4) throw new RangeError("baseColor");
|
||||
this.#values().set(value, 0);
|
||||
}
|
||||
get metallic() { return this.#values()[4]; }
|
||||
set metallic(value: number) { this.#values()[4] = value; }
|
||||
get roughness() { return this.#values()[5]; }
|
||||
set roughness(value: number) { this.#values()[5] = value; }
|
||||
get emissive() { return this.#values().subarray(6, 9); }
|
||||
set emissive(value: ArrayLike<number>) {
|
||||
if (value.length !== 3) throw new RangeError("emissive");
|
||||
this.#values().set(value, 6);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
await this.scene.core.deleteObject("materials", this.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type ShaderMaterialOptions = {
|
||||
code: string;
|
||||
vertexEntry?: string;
|
||||
fragmentEntry?: string;
|
||||
};
|
||||
|
||||
/** User WGSL represented as a material handle; registration rebuilds the Scene graph loadout. */
|
||||
export class ShaderMaterial {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
code: string;
|
||||
vertexEntry: string;
|
||||
fragmentEntry: string;
|
||||
readonly ready: Promise<this>;
|
||||
#disposed = false;
|
||||
|
||||
constructor(scene: Scene, options: ShaderMaterialOptions) {
|
||||
if (!options?.code) throw new TypeError("ShaderMaterial code is required");
|
||||
this.scene = scene;
|
||||
this.code = options.code;
|
||||
this.vertexEntry = options.vertexEntry ?? "vertex";
|
||||
this.fragmentEntry = options.fragmentEntry ?? "fragment";
|
||||
this.ready = scene.allocateMaterial().then(async (id) => {
|
||||
this.id = id;
|
||||
await scene.registerShader(this);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
async update(options: Partial<ShaderMaterialOptions>) {
|
||||
await this.ready;
|
||||
if (options.code !== undefined) this.code = options.code;
|
||||
if (options.vertexEntry !== undefined) this.vertexEntry = options.vertexEntry;
|
||||
if (options.fragmentEntry !== undefined) this.fragmentEntry = options.fragmentEntry;
|
||||
await this.scene.registerShader(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
await this.scene.unregisterShader(this.id);
|
||||
await this.scene.core.deleteObject("materials", this.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type TextureOptions = {
|
||||
source?: string | ImageBitmap;
|
||||
size?: [number | "canvas", number | "canvas", number?];
|
||||
format?: string;
|
||||
usage?: string[];
|
||||
mipmaps?: boolean;
|
||||
transient?: boolean;
|
||||
};
|
||||
|
||||
let nextTextureName = 1;
|
||||
|
||||
/** A graph texture resource handle; image decoding/upload policy remains outside core. */
|
||||
export class Texture {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
readonly resource: string;
|
||||
readonly source?: string | ImageBitmap;
|
||||
readonly ready: Promise<void>;
|
||||
#disposed = false;
|
||||
|
||||
constructor(scene: Scene, options: TextureOptions = {}) {
|
||||
this.scene = scene;
|
||||
const resource = `texture-${nextTextureName++}`;
|
||||
this.resource = resource;
|
||||
this.source = options.source;
|
||||
this.ready = (async () => {
|
||||
const image =
|
||||
typeof options.source === "string"
|
||||
? await createImageBitmap(await (await fetch(options.source)).blob())
|
||||
: options.source;
|
||||
const mipLevelCount =
|
||||
image instanceof ImageBitmap && options.mipmaps !== false
|
||||
? Math.floor(Math.log2(Math.max(image.width, image.height))) + 1
|
||||
: 1;
|
||||
const registration = scene.registerTexture({
|
||||
id: resource,
|
||||
source: image,
|
||||
size:
|
||||
options.size ??
|
||||
(image instanceof ImageBitmap
|
||||
? [image.width, image.height, 1]
|
||||
: [1, 1, 1]),
|
||||
format: options.format ?? "rgba8unorm",
|
||||
mipLevelCount,
|
||||
usage: [
|
||||
...new Set([
|
||||
...(options.usage ?? ["copyDst"]),
|
||||
"sampled",
|
||||
...(image instanceof ImageBitmap ? ["render"] : []),
|
||||
]),
|
||||
],
|
||||
transient: options.transient ?? false,
|
||||
});
|
||||
this.id = registration.number;
|
||||
await registration.ready;
|
||||
if (image instanceof ImageBitmap) {
|
||||
const levels = [image];
|
||||
for (let level = 1; level < mipLevelCount; level++)
|
||||
levels.push(
|
||||
await createImageBitmap(image, {
|
||||
resizeWidth: Math.max(1, image.width >> level),
|
||||
resizeHeight: Math.max(1, image.height >> level),
|
||||
resizeQuality: "high",
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
levels.map((level, index) =>
|
||||
scene.core.uploadTexture(resource, level, index),
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
await this.scene.core.deleteTexture(this.resource);
|
||||
await this.scene.unregisterTexture(this.id);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "@yawn/mesh-handles",
|
||||
"version": "0.1.0",
|
||||
"description": "Conventional mesh, instance, camera, and material handles over Yawn render data",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js"
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
class Handle {
|
||||
constructor(array, row = 0) {
|
||||
this.array = array;
|
||||
this.row = row;
|
||||
}
|
||||
get state() { return this.array.read(this.row); }
|
||||
set state(value) { this.array.write(this.row, value); }
|
||||
patch(offset, values) {
|
||||
const state = this.state;
|
||||
state.splice(offset, values.length, ...values);
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
/** Conventional camera values over one caller-owned shared row. */
|
||||
export class CameraHandle extends Handle {
|
||||
static async create(core, name = "camera") {
|
||||
const array = await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" });
|
||||
const camera = new CameraHandle(array);
|
||||
camera.state = [0, 0, 4, 0, 0, 0, 0, 0, 0, 1, 0, 0, Math.PI / 3, 1, 0.1, 1000];
|
||||
return camera;
|
||||
}
|
||||
get position() { return this.state.slice(0, 3); }
|
||||
set position(value) { this.patch(0, value); }
|
||||
get target() { return this.state.slice(4, 7); }
|
||||
set target(value) { this.patch(4, value); }
|
||||
}
|
||||
|
||||
/** Conventional material properties over one eight-float shared row. */
|
||||
export class MaterialHandle extends Handle {
|
||||
static async create(core, name = "material") {
|
||||
const material = new MaterialHandle(await core.allocateRows({ name, rows: 1, stride: 32, format: "f32" }));
|
||||
material.state = [1, 1, 1, 1, 0, 1, 0, 0];
|
||||
return material;
|
||||
}
|
||||
get baseColor() { return this.state.slice(0, 4); }
|
||||
set baseColor(value) { this.patch(0, value); }
|
||||
get metallic() { return this.state[4]; }
|
||||
set metallic(value) { this.patch(4, [value]); }
|
||||
get roughness() { return this.state[5]; }
|
||||
set roughness(value) { this.patch(5, [value]); }
|
||||
}
|
||||
|
||||
/** Conventional mesh transform over one SIMD-aligned shared row. */
|
||||
export class MeshHandle extends Handle {
|
||||
static async create(core, name = "mesh") {
|
||||
const mesh = new MeshHandle(await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" }));
|
||||
mesh.transform = identity;
|
||||
return mesh;
|
||||
}
|
||||
get transform() { return this.state; }
|
||||
set transform(value) { this.state = value; }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "@yawn/render-graph-ast",
|
||||
"version": "0.1.0",
|
||||
"description": "Canonical Yawn render-graph AST and S-expression codec",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
export const GRAPH_AST_VERSION = 1;
|
||||
|
||||
const data = value => value === null || typeof value === "string" || typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
(Array.isArray(value) && value.every(data)) ||
|
||||
(value?.constructor === Object && Object.values(value).every(data));
|
||||
|
||||
function freeze(value) {
|
||||
if (value && typeof value === "object") {
|
||||
Object.values(value).forEach(freeze);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Creates the data-only AST consumed by every graph frontend. DAG edges are pass `after` IDs. */
|
||||
export function createGraphAst(graph) {
|
||||
if (!data(graph) || graph?.constructor !== Object || typeof graph.id !== "string" ||
|
||||
!Array.isArray(graph.passes)) throw new TypeError("GRAPH_AST");
|
||||
return freeze(structuredClone(graph));
|
||||
}
|
||||
|
||||
function encode(value) {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `(array${value.map(item => ` ${encode(item)}`).join("")})`;
|
||||
return `(object${Object.keys(value).sort().map(key =>
|
||||
` (field ${JSON.stringify(key)} ${encode(value[key])})`).join("")})`;
|
||||
}
|
||||
|
||||
/** Serializes the AST as an S-expression; named pass references preserve DAG fan-out. */
|
||||
export function serializeGraphAst(graph) {
|
||||
return `(yawn-graph ${GRAPH_AST_VERSION} ${encode(createGraphAst(graph))})`;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "@yawn/render-graph-fxnode",
|
||||
"version": "0.1.0",
|
||||
"description": "FXNode frontend for Yawn render graphs",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@yawn/render-graph-ast": "0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { createGraphAst } from "@yawn/render-graph-ast";
|
||||
|
||||
/** Exports FXNode nodes with a `pass` payload and links as the canonical pass DAG. */
|
||||
export function adaptFxNodeSnapshot(snapshot, { pipelines = {}, resources = {} } = {}) {
|
||||
if (!Array.isArray(snapshot?.nodes) || !Array.isArray(snapshot?.links)) throw new TypeError("FXNODE_GRAPH");
|
||||
const passes = snapshot.nodes.map(node => {
|
||||
if (!node?.id || !node.pass) throw new TypeError("FXNODE_PASS");
|
||||
return { ...structuredClone(node.pass), id: node.id, after: [...(node.pass.after ?? [])] };
|
||||
});
|
||||
const byId = new Map(passes.map(pass => [pass.id, pass]));
|
||||
for (const link of snapshot.links) {
|
||||
const source = link.fromNodeId ?? link.from?.node;
|
||||
const target = link.toNodeId ?? link.to?.node;
|
||||
if (!byId.has(source) || !byId.has(target)) throw new TypeError("FXNODE_LINK");
|
||||
if (!byId.get(target).after.includes(source)) byId.get(target).after.push(source);
|
||||
}
|
||||
return createGraphAst({ id: snapshot.graphId ?? "fxnode", pipelines, resources, passes });
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"name": "@yawn/render-graph-js",
|
||||
"version": "0.1.0",
|
||||
"description": "JSO and builder frontend for Yawn render graphs",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@yawn/render-graph-ast": "0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
|
||||
|
||||
/** Converts a plain JavaScript object into the canonical render-graph AST. */
|
||||
export const graphFromObject = graph => createGraphAst(graph);
|
||||
|
||||
/** Serializes a JSO graph and asks Yawn Core to prepare and activate its loadout. */
|
||||
export function loadGraph(core, graph) {
|
||||
if (!core?.loadGraph) throw new TypeError("core must be a YawnCore instance");
|
||||
return core.loadGraph(serializeGraphAst(graphFromObject(graph)));
|
||||
}
|
||||
|
||||
export { serializeGraphAst };
|
||||
@@ -6,7 +6,13 @@ edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
path = "lib.rs"
|
||||
|
||||
[dependencies]
|
||||
js-sys = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["ImageBitmap", "OffscreenCanvas"] }
|
||||
wgpu = { version = "26", default-features = false, features = ["serde", "webgpu", "wgsl"] }
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array });
|
||||
|
||||
export class SharedRows {
|
||||
constructor(buffer, descriptor) {
|
||||
if (!(buffer instanceof SharedArrayBuffer)) throw new TypeError("ROWS_DESCRIPTOR");
|
||||
this.buffer = buffer;
|
||||
this.update(descriptor);
|
||||
}
|
||||
|
||||
update(descriptor) {
|
||||
if (!views[descriptor.format] || descriptor.name !== (this.descriptor?.name ?? descriptor.name))
|
||||
throw new TypeError("ROWS_DESCRIPTOR");
|
||||
this.descriptor = Object.freeze(descriptor);
|
||||
return this;
|
||||
}
|
||||
|
||||
get name() { return this.descriptor.name; }
|
||||
get rows() { return this.descriptor.rows; }
|
||||
get stride() { return this.descriptor.stride; }
|
||||
get format() { return this.descriptor.format; }
|
||||
get view() {
|
||||
const View = views[this.format];
|
||||
return new View(this.buffer, this.descriptor.offset, this.descriptor.bytes / View.BYTES_PER_ELEMENT);
|
||||
}
|
||||
|
||||
row(index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE");
|
||||
const width = this.stride / views[this.format].BYTES_PER_ELEMENT;
|
||||
return this.view.subarray(index * width, (index + 1) * width);
|
||||
}
|
||||
|
||||
read(index) { return Array.from(this.row(index)); }
|
||||
|
||||
write(index, values) {
|
||||
const row = this.row(index);
|
||||
if (!values || values.length !== row.length) throw new RangeError("ROW_WIDTH");
|
||||
row.set(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
share() { return { buffer: this.buffer, descriptor: this.descriptor }; }
|
||||
}
|
||||
|
||||
export class YawnCore {
|
||||
#worker;
|
||||
#buffer;
|
||||
#arrays = new Map();
|
||||
#pending = new Map();
|
||||
#profileListeners = new Set();
|
||||
#next = 1;
|
||||
|
||||
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, debug = false, workerFactory } = {}) {
|
||||
if (!canvas) throw new TypeError("canvas is required");
|
||||
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-core",
|
||||
});
|
||||
this.#worker.addEventListener("message", ({ data }) => this.#message(data));
|
||||
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
||||
this.#worker.start?.();
|
||||
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
||||
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(async result => {
|
||||
this.#buffer = result.buffer;
|
||||
for (const descriptor of result.rows) this.#arrays.set(
|
||||
descriptor.name,
|
||||
new SharedRows(this.#buffer, descriptor),
|
||||
);
|
||||
if (debug) await this.#request("set-profiler", { enabled: true });
|
||||
});
|
||||
}
|
||||
|
||||
async createRows({ name, rows, stride, format }) {
|
||||
await this.ready;
|
||||
const descriptor = await this.#request("create-rows", { name, rows, stride, format });
|
||||
const array = this.#arrays.get(name)?.update(descriptor)
|
||||
?? new SharedRows(this.#buffer, descriptor);
|
||||
this.#arrays.set(name, array);
|
||||
return array;
|
||||
}
|
||||
|
||||
async createRowsBatch(rows) {
|
||||
await this.ready;
|
||||
if (!Array.isArray(rows) || !rows.length) throw new TypeError("ROWS");
|
||||
const descriptors = await this.#request("create-rows-batch", { rows });
|
||||
return descriptors.map(descriptor => {
|
||||
const array = this.#arrays.get(descriptor.name)?.update(descriptor)
|
||||
?? new SharedRows(this.#buffer, descriptor);
|
||||
this.#arrays.set(descriptor.name, array);
|
||||
return array;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRows(name) {
|
||||
await this.ready;
|
||||
await this.#request("delete-rows", { name });
|
||||
this.#arrays.delete(name);
|
||||
}
|
||||
|
||||
async allocateObject(name) {
|
||||
await this.ready;
|
||||
const { id, rows } = await this.#request("allocate-object", { name });
|
||||
this.#arrays.get(name).update(rows);
|
||||
return id;
|
||||
}
|
||||
|
||||
async deleteObject(name, id) {
|
||||
await this.ready;
|
||||
return this.#request("delete-object", { name, id });
|
||||
}
|
||||
|
||||
async compileGraph(serialized) {
|
||||
await this.ready;
|
||||
return this.#request("compile-graph", { serialized });
|
||||
}
|
||||
|
||||
async switchLoadout(id) {
|
||||
await this.ready;
|
||||
return this.#request("switch-loadout", { id });
|
||||
}
|
||||
|
||||
async uploadTexture(name, image, mipLevel = 0) {
|
||||
await this.ready;
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
!(image instanceof ImageBitmap) ||
|
||||
!Number.isInteger(mipLevel) ||
|
||||
mipLevel < 0
|
||||
)
|
||||
throw new TypeError("TEXTURE_SOURCE");
|
||||
return this.#request(
|
||||
"upload-texture",
|
||||
{ name, image, mipLevel },
|
||||
[image],
|
||||
);
|
||||
}
|
||||
|
||||
async deleteTexture(name) {
|
||||
await this.ready;
|
||||
return this.#request("delete-texture", { name });
|
||||
}
|
||||
|
||||
async play() {
|
||||
await this.ready;
|
||||
return this.#request("play");
|
||||
}
|
||||
|
||||
async pause() {
|
||||
await this.ready;
|
||||
return this.#request("pause");
|
||||
}
|
||||
|
||||
async setFps(fps) {
|
||||
await this.ready;
|
||||
return this.#request("set-fps", { fps });
|
||||
}
|
||||
|
||||
async setProfiler(enabled) {
|
||||
await this.ready;
|
||||
return this.#request("set-profiler", { enabled: Boolean(enabled) });
|
||||
}
|
||||
|
||||
onProfile(listener) {
|
||||
if (typeof listener !== "function") throw new TypeError("PROFILE_LISTENER");
|
||||
this.#profileListeners.add(listener);
|
||||
return () => this.#profileListeners.delete(listener);
|
||||
}
|
||||
|
||||
array(name) {
|
||||
const array = this.#arrays.get(name);
|
||||
if (!array) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
||||
return array;
|
||||
}
|
||||
|
||||
#request(type, payload = {}, transfer = []) {
|
||||
const request = this.#next++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pending.set(request, { resolve, reject });
|
||||
this.#worker.postMessage({ type, request, ...payload }, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
#message(message) {
|
||||
if (message?.type === "profile") {
|
||||
for (const listener of this.#profileListeners) listener(message.stats);
|
||||
return;
|
||||
}
|
||||
const pending = this.#pending.get(message?.request);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(message.request);
|
||||
if (message.error) pending.reject(Object.assign(new Error(message.error), { code: message.error }));
|
||||
else pending.resolve(message.result);
|
||||
}
|
||||
|
||||
#fail(code) {
|
||||
for (const { reject } of this.#pending.values()) reject(Object.assign(new Error(code), { code }));
|
||||
this.#pending.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#fail("DISPOSED");
|
||||
this.#profileListeners.clear();
|
||||
this.#worker.terminate();
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[path = "render_graph/compiler.rs"]
|
||||
mod compiler;
|
||||
#[path = "renderer/wgpu.rs"]
|
||||
mod gpu;
|
||||
#[path = "render_graph/gpu_resource.rs"]
|
||||
mod gpu_resource;
|
||||
#[path = "render_graph/render_graph.rs"]
|
||||
mod graph;
|
||||
#[path = "renderer/render.rs"]
|
||||
mod render;
|
||||
mod render_data;
|
||||
#[path = "render_graph/store.rs"]
|
||||
mod store;
|
||||
|
||||
use gpu::Wgpu;
|
||||
use render::RenderLoop;
|
||||
use render_data::RenderData;
|
||||
use store::Store;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RowRequest {
|
||||
name: String,
|
||||
rows: u32,
|
||||
stride: u32,
|
||||
format: String,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Core {
|
||||
data: Rc<RefCell<RenderData>>,
|
||||
gpu: Rc<RefCell<Option<Wgpu>>>,
|
||||
store: Rc<RefCell<Store>>,
|
||||
render: Rc<RenderLoop>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Core {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(arena_bytes: u32) -> Result<Self, JsError> {
|
||||
Ok(Self {
|
||||
data: Rc::new(RefCell::new(
|
||||
RenderData::new(arena_bytes).map_err(JsError::new)?,
|
||||
)),
|
||||
gpu: Rc::new(RefCell::new(None)),
|
||||
store: Rc::new(RefCell::new(Store::default())),
|
||||
render: Rc::new(RenderLoop::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&self, canvas: web_sys::OffscreenCanvas) -> Result<(), JsError> {
|
||||
let gpu = Wgpu::new(canvas)
|
||||
.await
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
*self.gpu.borrow_mut() = Some(gpu);
|
||||
self.render
|
||||
.start(self.gpu.clone(), self.store.clone(), self.data.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> String {
|
||||
serde_json::to_string(&self.data.borrow().descriptors()).unwrap()
|
||||
}
|
||||
|
||||
pub fn create_rows(
|
||||
&self,
|
||||
name: String,
|
||||
rows: u32,
|
||||
stride: u32,
|
||||
format: String,
|
||||
) -> Result<String, JsError> {
|
||||
let rows = self
|
||||
.data
|
||||
.borrow_mut()
|
||||
.create_rows(name, rows, stride, format)
|
||||
.map_err(JsError::new)?;
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh_rows(&rows.name, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
}
|
||||
Ok(serde_json::to_string(&rows).unwrap())
|
||||
}
|
||||
|
||||
pub fn create_rows_batch(&self, source: &str) -> Result<String, JsError> {
|
||||
let requests: Vec<RowRequest> =
|
||||
serde_json::from_str(source).map_err(|_| JsError::new("ROWS"))?;
|
||||
if requests.is_empty() {
|
||||
return Err(JsError::new("ROWS"));
|
||||
}
|
||||
let refresh = requests
|
||||
.iter()
|
||||
.any(|request| self.store.borrow().uses_rows(&request.name));
|
||||
let descriptors = {
|
||||
let mut data = self.data.borrow_mut();
|
||||
requests
|
||||
.into_iter()
|
||||
.map(|request| {
|
||||
data.create_rows(request.name, request.rows, request.stride, request.format)
|
||||
.map_err(JsError::new)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
if refresh {
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh(gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
}
|
||||
}
|
||||
Ok(serde_json::to_string(&descriptors).unwrap())
|
||||
}
|
||||
|
||||
pub fn delete_rows(&self, name: String) -> Result<(), JsError> {
|
||||
if self.store.borrow().uses_rows(&name) {
|
||||
return Err(JsError::new("ROWS_ACTIVE"));
|
||||
}
|
||||
self.data
|
||||
.borrow_mut()
|
||||
.delete_rows(&name)
|
||||
.map_err(JsError::new)
|
||||
}
|
||||
|
||||
pub fn allocate_object(&self, name: &str) -> Result<String, JsError> {
|
||||
let (id, grew) = self
|
||||
.data
|
||||
.borrow_mut()
|
||||
.allocate_object(name)
|
||||
.map_err(JsError::new)?;
|
||||
let rows = self
|
||||
.data
|
||||
.borrow()
|
||||
.rows(name)
|
||||
.ok_or_else(|| JsError::new("ROWS_UNKNOWN"))?
|
||||
.clone();
|
||||
if grew {
|
||||
if let Some(gpu) = self.gpu.borrow().as_ref() {
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.refresh_rows(name, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "id": id, "rows": rows }).to_string())
|
||||
}
|
||||
|
||||
pub fn delete_object(&self, name: &str, id: u32) -> Result<(), JsError> {
|
||||
self.data
|
||||
.borrow_mut()
|
||||
.delete_object(name, id)
|
||||
.map_err(JsError::new)?;
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile_graph(&self, source: &str) -> Result<String, JsError> {
|
||||
let graph = compiler::compile(source).map_err(JsError::new)?;
|
||||
Ok(self.store.borrow_mut().save(graph))
|
||||
}
|
||||
|
||||
pub fn switch_loadout(&self, id: &str) -> Result<(), JsError> {
|
||||
let gpu = self.gpu.borrow();
|
||||
let gpu = gpu
|
||||
.as_ref()
|
||||
.ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?;
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.switch(id, gpu, &self.data.borrow())
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
self.data.borrow_mut().loadout_ready();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upload_texture(
|
||||
&self,
|
||||
name: String,
|
||||
mip_level: u32,
|
||||
image: web_sys::ImageBitmap,
|
||||
) -> Result<(), JsError> {
|
||||
let gpu = self.gpu.borrow();
|
||||
let gpu = gpu
|
||||
.as_ref()
|
||||
.ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?;
|
||||
self.store
|
||||
.borrow_mut()
|
||||
.upload_texture(name, mip_level, image, gpu)
|
||||
.map_err(|error| JsError::new(&error))?;
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_texture(&self, name: &str) {
|
||||
self.store.borrow_mut().delete_texture(name);
|
||||
}
|
||||
|
||||
pub fn play(&self) {
|
||||
self.render.play();
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
self.render.pause();
|
||||
}
|
||||
|
||||
pub fn set_fps(&self, fps: u32) -> Result<(), JsError> {
|
||||
self.render.set_fps(fps).map_err(JsError::new)
|
||||
}
|
||||
|
||||
pub fn set_profiler(&self, enabled: bool) -> bool {
|
||||
let supported = self
|
||||
.gpu
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|gpu| gpu.timestamp_queries);
|
||||
self.render.set_profiling(enabled && supported);
|
||||
if enabled && supported {
|
||||
self.data.borrow_mut().mark_dirty();
|
||||
}
|
||||
supported
|
||||
}
|
||||
|
||||
pub fn take_profile(&self) -> Option<String> {
|
||||
self.render.take_profile()
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -3,6 +3,6 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Rust/WASM render data and render graph core",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js",
|
||||
"files": ["src", "pkg"]
|
||||
"exports": "./index.js",
|
||||
"files": ["index.js", "worker.js", "pkg"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
const ALIGNMENT: u32 = 64;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Rows {
|
||||
pub name: String,
|
||||
pub rows: u32,
|
||||
pub stride: u32,
|
||||
pub format: String,
|
||||
pub offset: u32,
|
||||
pub bytes: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Slots {
|
||||
active: HashSet<u32>,
|
||||
free: Vec<u32>,
|
||||
next: u32,
|
||||
}
|
||||
|
||||
pub struct RenderData {
|
||||
arena: Box<[u8]>,
|
||||
arena_start: usize,
|
||||
base: u32,
|
||||
free: BTreeMap<u32, u32>,
|
||||
rows: HashMap<String, Rows>,
|
||||
slots: HashMap<String, Slots>,
|
||||
}
|
||||
|
||||
impl RenderData {
|
||||
pub fn new(capacity: u32) -> Result<Self, &'static str> {
|
||||
if capacity < ALIGNMENT || capacity > u32::MAX - (ALIGNMENT - 1) {
|
||||
return Err("INIT");
|
||||
}
|
||||
let mut arena = vec![0; (capacity + ALIGNMENT - 1) as usize].into_boxed_slice();
|
||||
let pointer = arena.as_mut_ptr() as usize;
|
||||
let aligned = (pointer + ALIGNMENT as usize - 1) & !(ALIGNMENT as usize - 1);
|
||||
let mut data = Self {
|
||||
arena,
|
||||
arena_start: aligned - pointer,
|
||||
base: aligned as u32,
|
||||
free: BTreeMap::from([(0, capacity)]),
|
||||
rows: HashMap::new(),
|
||||
slots: HashMap::new(),
|
||||
};
|
||||
data.create_rows("signals".into(), 1, 32, "f32".into())?;
|
||||
data.write_signal(5, 1.0);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub fn create_rows(
|
||||
&mut self,
|
||||
name: String,
|
||||
rows: u32,
|
||||
stride: u32,
|
||||
format: String,
|
||||
) -> Result<Rows, &'static str> {
|
||||
if name.is_empty()
|
||||
|| rows == 0
|
||||
|| stride < 16
|
||||
|| stride % 16 != 0
|
||||
|| !matches!(format.as_str(), "f32" | "u32" | "i32")
|
||||
{
|
||||
return Err("ROWS");
|
||||
}
|
||||
if let Some(current) = self.rows.get(&name).cloned() {
|
||||
if current.stride != stride || current.format != format {
|
||||
return Err("ROWS");
|
||||
}
|
||||
if rows <= current.rows {
|
||||
return Ok(current);
|
||||
}
|
||||
if name == "signals" {
|
||||
return Err("ROWS_BUILTIN");
|
||||
}
|
||||
return self.grow_rows(current, rows);
|
||||
}
|
||||
let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?;
|
||||
let offset = self.reserve(bytes)?;
|
||||
let end = offset + bytes;
|
||||
self.arena[self.arena_start + offset as usize..self.arena_start + end as usize].fill(0);
|
||||
let descriptor = Rows {
|
||||
name: name.clone(),
|
||||
rows,
|
||||
stride,
|
||||
format,
|
||||
offset: self.base + offset,
|
||||
bytes,
|
||||
};
|
||||
self.rows.insert(name, descriptor.clone());
|
||||
self.slots.insert(descriptor.name.clone(), Slots::default());
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> {
|
||||
if name == "signals" {
|
||||
return Err("ROWS_BUILTIN");
|
||||
}
|
||||
if self
|
||||
.slots
|
||||
.get(name)
|
||||
.is_some_and(|slots| !slots.active.is_empty())
|
||||
{
|
||||
return Err("ROWS_ACTIVE");
|
||||
}
|
||||
let rows = self.rows.remove(name).ok_or("ROWS_UNKNOWN")?;
|
||||
self.slots.remove(name);
|
||||
self.release(rows.offset - self.base, rows.bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rows(&self, name: &str) -> Option<&Rows> {
|
||||
self.rows.get(name)
|
||||
}
|
||||
|
||||
pub fn descriptors(&self) -> Vec<Rows> {
|
||||
let mut rows = self.rows.values().cloned().collect::<Vec<_>>();
|
||||
rows.sort_by_key(|rows| rows.offset);
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn bytes(&self, name: &str) -> Option<&[u8]> {
|
||||
let rows = self.rows(name)?;
|
||||
let start = self.arena_start + (rows.offset - self.base) as usize;
|
||||
Some(&self.arena[start..start + rows.bytes as usize])
|
||||
}
|
||||
|
||||
pub fn allocate_object(&mut self, name: &str) -> Result<(u32, bool), &'static str> {
|
||||
if name == "signals" {
|
||||
return Err("ROWS_BUILTIN");
|
||||
}
|
||||
let slots = self.slots.get(name).ok_or("ROWS_UNKNOWN")?;
|
||||
let reused = slots.free.last().copied();
|
||||
let id = reused.unwrap_or(slots.next);
|
||||
let next = id.checked_add(1).ok_or("OBJECT_LIMIT")?;
|
||||
let capacity = self.rows[name].rows;
|
||||
let grew = id >= capacity;
|
||||
if grew {
|
||||
let descriptor = self.rows[name].clone();
|
||||
self.grow_rows(descriptor, next)?;
|
||||
}
|
||||
let slots = self.slots.get_mut(name).unwrap();
|
||||
if reused.is_some() {
|
||||
slots.free.pop();
|
||||
} else {
|
||||
slots.next = next;
|
||||
}
|
||||
slots.active.insert(id);
|
||||
Ok((id, grew))
|
||||
}
|
||||
|
||||
pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> {
|
||||
let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?;
|
||||
if !slots.active.remove(&id) {
|
||||
return Err("OBJECT_UNKNOWN");
|
||||
}
|
||||
let rows = self.rows[name].clone();
|
||||
let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize;
|
||||
self.arena[start..start + rows.stride as usize].fill(0);
|
||||
slots.free.push(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result<Rows, &'static str> {
|
||||
let bytes = rows.checked_mul(descriptor.stride).ok_or("ARENA_OOM")?;
|
||||
let old_offset = descriptor.offset - self.base;
|
||||
let old_end = old_offset + descriptor.bytes;
|
||||
let extra = bytes - descriptor.bytes;
|
||||
if self
|
||||
.free
|
||||
.get(&old_end)
|
||||
.is_some_and(|available| *available >= extra)
|
||||
{
|
||||
let available = self.free.remove(&old_end).unwrap();
|
||||
if available > extra {
|
||||
self.free.insert(old_end + extra, available - extra);
|
||||
}
|
||||
self.arena[self.arena_start + old_end as usize
|
||||
..self.arena_start + (old_end + extra) as usize]
|
||||
.fill(0);
|
||||
} else {
|
||||
let offset = self.reserve(bytes)?;
|
||||
let start = self.arena_start + offset as usize;
|
||||
self.arena[start..start + bytes as usize].fill(0);
|
||||
self.arena.copy_within(
|
||||
self.arena_start + old_offset as usize..self.arena_start + old_end as usize,
|
||||
start,
|
||||
);
|
||||
self.release(old_offset, descriptor.bytes);
|
||||
descriptor.offset = self.base + offset;
|
||||
}
|
||||
descriptor.rows = rows;
|
||||
descriptor.bytes = bytes;
|
||||
self.rows
|
||||
.insert(descriptor.name.clone(), descriptor.clone());
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
pub fn update_signals(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) {
|
||||
let start = self.arena_start + (self.rows["signals"].offset - self.base) as usize;
|
||||
for (index, value) in [delta, frame as f32, elapsed, fps as f32]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
self.arena[start + index * 4..start + index * 4 + 4]
|
||||
.copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_pending(&self) -> bool {
|
||||
self.signal(4) == 0.0 && self.signal(5) != 0.0 && self.signal(6) == 0.0
|
||||
}
|
||||
|
||||
pub fn begin_render(&mut self) -> bool {
|
||||
if !self.render_pending() {
|
||||
return false;
|
||||
}
|
||||
self.write_signal(5, 0.0);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.write_signal(5, 1.0);
|
||||
}
|
||||
|
||||
pub fn loadout_ready(&mut self) {
|
||||
self.write_signal(6, 0.0);
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
fn signal(&self, index: usize) -> f32 {
|
||||
let rows = &self.rows["signals"];
|
||||
let start = self.arena_start + (rows.offset - self.base) as usize + index * 4;
|
||||
f32::from_le_bytes(self.arena[start..start + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn write_signal(&mut self, index: usize, value: f32) {
|
||||
let rows = &self.rows["signals"];
|
||||
let start = self.arena_start + (rows.offset - self.base) as usize + index * 4;
|
||||
self.arena[start..start + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn reserve(&mut self, bytes: u32) -> Result<u32, &'static str> {
|
||||
let (block, block_bytes, offset) = self
|
||||
.free
|
||||
.iter()
|
||||
.find_map(|(&block, &block_bytes)| {
|
||||
let offset = align(block)?;
|
||||
(offset.checked_add(bytes)? <= block.checked_add(block_bytes)?).then_some((
|
||||
block,
|
||||
block_bytes,
|
||||
offset,
|
||||
))
|
||||
})
|
||||
.ok_or("ARENA_OOM")?;
|
||||
self.free.remove(&block);
|
||||
if offset > block {
|
||||
self.free.insert(block, offset - block);
|
||||
}
|
||||
let end = offset + bytes;
|
||||
let block_end = block + block_bytes;
|
||||
if end < block_end {
|
||||
self.free.insert(end, block_end - end);
|
||||
}
|
||||
Ok(offset)
|
||||
}
|
||||
|
||||
fn release(&mut self, mut offset: u32, mut bytes: u32) {
|
||||
if let Some((&previous, &previous_bytes)) = self.free.range(..offset).next_back() {
|
||||
if previous + previous_bytes == offset {
|
||||
self.free.remove(&previous);
|
||||
offset = previous;
|
||||
bytes += previous_bytes;
|
||||
}
|
||||
}
|
||||
if let Some((&next, &next_bytes)) = self.free.range(offset..).next() {
|
||||
if offset + bytes == next {
|
||||
self.free.remove(&next);
|
||||
bytes += next_bytes;
|
||||
}
|
||||
}
|
||||
self.free.insert(offset, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn align(value: u32) -> Option<u32> {
|
||||
value
|
||||
.checked_add(ALIGNMENT - 1)
|
||||
.map(|value| value & !(ALIGNMENT - 1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_data_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,32 @@
|
||||
use super::RenderData;
|
||||
|
||||
#[test]
|
||||
fn dirty_signal_is_consumed_once() {
|
||||
let mut data = RenderData::new(64).unwrap();
|
||||
|
||||
assert!(data.rows("signals").is_some());
|
||||
assert!(data.begin_render());
|
||||
assert!(!data.begin_render());
|
||||
|
||||
data.mark_dirty();
|
||||
assert!(data.begin_render());
|
||||
assert!(!data.render_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_and_bundle_signals_hold_dirty_work() {
|
||||
let mut data = RenderData::new(64).unwrap();
|
||||
assert!(data.begin_render());
|
||||
|
||||
data.write_signal(4, 1.0);
|
||||
data.mark_dirty();
|
||||
assert!(!data.begin_render());
|
||||
data.write_signal(4, 0.0);
|
||||
assert!(data.begin_render());
|
||||
|
||||
data.write_signal(6, 1.0);
|
||||
data.mark_dirty();
|
||||
assert!(!data.begin_render());
|
||||
data.loadout_ready();
|
||||
assert!(data.begin_render());
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::graph::RenderGraph;
|
||||
|
||||
pub fn compile(source: &str) -> Result<RenderGraph, &'static str> {
|
||||
let Expression::List(mut root) = Parser::parse(source)? else {
|
||||
return Err("GRAPH_WIRE");
|
||||
};
|
||||
if root.len() != 3
|
||||
|| !matches!(&root[0], Expression::Atom(Value::String(tag)) if tag == "yawn-graph")
|
||||
|| !matches!(&root[1], Expression::Atom(Value::Number(version)) if version.as_u64() == Some(1))
|
||||
{
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let value = decode(root.pop().unwrap())?;
|
||||
let mut graph: RenderGraph = serde_json::from_value(value).map_err(|_| "GRAPH_SHAPE")?;
|
||||
graph.prepare()?;
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
enum Expression {
|
||||
Atom(Value),
|
||||
List(Vec<Expression>),
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
source: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn parse(source: &'a str) -> Result<Expression, &'static str> {
|
||||
let mut parser = Self {
|
||||
source: source.as_bytes(),
|
||||
at: 0,
|
||||
};
|
||||
let expression = parser.expression()?;
|
||||
parser.whitespace();
|
||||
(parser.at == parser.source.len())
|
||||
.then_some(expression)
|
||||
.ok_or("GRAPH_WIRE")
|
||||
}
|
||||
|
||||
fn expression(&mut self) -> Result<Expression, &'static str> {
|
||||
self.whitespace();
|
||||
match self.source.get(self.at) {
|
||||
Some(b'(') => self.list(),
|
||||
Some(b'"') => self.string(),
|
||||
Some(b')') | None => Err("GRAPH_WIRE"),
|
||||
Some(_) => self.atom(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(&mut self) -> Result<Expression, &'static str> {
|
||||
self.at += 1;
|
||||
let mut values = Vec::new();
|
||||
loop {
|
||||
self.whitespace();
|
||||
match self.source.get(self.at) {
|
||||
Some(b')') => {
|
||||
self.at += 1;
|
||||
return Ok(Expression::List(values));
|
||||
}
|
||||
None => return Err("GRAPH_WIRE"),
|
||||
_ => values.push(self.expression()?),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<Expression, &'static str> {
|
||||
let start = self.at;
|
||||
self.at += 1;
|
||||
let mut escaped = false;
|
||||
while let Some(&byte) = self.source.get(self.at) {
|
||||
self.at += 1;
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
let value = serde_json::from_slice(&self.source[start..self.at])
|
||||
.map_err(|_| "GRAPH_WIRE")?;
|
||||
return Ok(Expression::Atom(Value::String(value)));
|
||||
}
|
||||
}
|
||||
Err("GRAPH_WIRE")
|
||||
}
|
||||
|
||||
fn atom(&mut self) -> Result<Expression, &'static str> {
|
||||
let start = self.at;
|
||||
while self
|
||||
.source
|
||||
.get(self.at)
|
||||
.is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b'(' | b')'))
|
||||
{
|
||||
self.at += 1;
|
||||
}
|
||||
let token = std::str::from_utf8(&self.source[start..self.at]).map_err(|_| "GRAPH_WIRE")?;
|
||||
let value = match token {
|
||||
"true" => Value::Bool(true),
|
||||
"false" => Value::Bool(false),
|
||||
"null" => Value::Null,
|
||||
_ => serde_json::from_str::<Value>(token)
|
||||
.ok()
|
||||
.filter(Value::is_number)
|
||||
.unwrap_or_else(|| Value::String(token.into())),
|
||||
};
|
||||
Ok(Expression::Atom(value))
|
||||
}
|
||||
|
||||
fn whitespace(&mut self) {
|
||||
while self
|
||||
.source
|
||||
.get(self.at)
|
||||
.is_some_and(u8::is_ascii_whitespace)
|
||||
{
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(expression: Expression) -> Result<Value, &'static str> {
|
||||
let Expression::List(mut values) = expression else {
|
||||
return match expression {
|
||||
Expression::Atom(value) => Ok(value),
|
||||
Expression::List(_) => unreachable!(),
|
||||
};
|
||||
};
|
||||
if values.is_empty() {
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let tag = match values.remove(0) {
|
||||
Expression::Atom(Value::String(tag)) => tag,
|
||||
_ => return Err("GRAPH_WIRE"),
|
||||
};
|
||||
if tag == "array" {
|
||||
return values.into_iter().map(decode).collect();
|
||||
}
|
||||
if tag != "object" {
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let mut object = Map::new();
|
||||
for field in values {
|
||||
let Expression::List(mut field) = field else {
|
||||
return Err("GRAPH_WIRE");
|
||||
};
|
||||
if field.len() != 3
|
||||
|| !matches!(&field[0], Expression::Atom(Value::String(tag)) if tag == "field")
|
||||
{
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let value = decode(field.pop().unwrap())?;
|
||||
let key = match field.pop().unwrap() {
|
||||
Expression::Atom(Value::String(key)) => key,
|
||||
_ => return Err("GRAPH_WIRE"),
|
||||
};
|
||||
if object.insert(key, value).is_some() {
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::gpu::Wgpu;
|
||||
use crate::graph::{Binding, Execution, Extent, Pass, RenderGraph, RenderPipeline, Texture};
|
||||
use crate::render_data::RenderData;
|
||||
|
||||
pub struct GpuResources {
|
||||
pub buffers: HashMap<String, GpuBuffer>,
|
||||
pub textures: Vec<GpuTexture>,
|
||||
pub texture_slots: HashMap<String, usize>,
|
||||
pub samplers: HashMap<String, wgpu::Sampler>,
|
||||
pub render_pipelines: HashMap<String, wgpu::RenderPipeline>,
|
||||
pub compute_pipelines: HashMap<String, wgpu::ComputePipeline>,
|
||||
pub passes: Vec<GpuPass>,
|
||||
pub profiler: Option<GpuProfiler>,
|
||||
}
|
||||
|
||||
pub struct GpuProfiler {
|
||||
query_set: wgpu::QuerySet,
|
||||
resolve: wgpu::Buffer,
|
||||
readback: Arc<wgpu::Buffer>,
|
||||
query_count: u32,
|
||||
}
|
||||
|
||||
pub struct ProfileMap {
|
||||
readback: Arc<wgpu::Buffer>,
|
||||
state: Arc<AtomicU8>,
|
||||
labels: Vec<String>,
|
||||
timestamp_period: f32,
|
||||
}
|
||||
|
||||
pub struct GpuBuffer {
|
||||
pub buffer: wgpu::Buffer,
|
||||
pub source: String,
|
||||
pub sync_each_frame: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GpuTexture {
|
||||
pub texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
key: String,
|
||||
uploaded_mips: HashSet<u32>,
|
||||
}
|
||||
|
||||
pub enum GpuPass {
|
||||
Render {
|
||||
label: String,
|
||||
first: usize,
|
||||
last: usize,
|
||||
bundle: wgpu::RenderBundle,
|
||||
},
|
||||
Compute {
|
||||
label: String,
|
||||
pass: usize,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
bind_groups: Vec<(u32, wgpu::BindGroup)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl GpuResources {
|
||||
pub fn activate(
|
||||
graph: &RenderGraph,
|
||||
gpu: &Wgpu,
|
||||
data: &RenderData,
|
||||
previous: Option<&Self>,
|
||||
) -> Result<Self, String> {
|
||||
let mut buffers = HashMap::new();
|
||||
for source in &graph.resources.buffers {
|
||||
let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?;
|
||||
let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(&source.id),
|
||||
size: u64::from(rows.bytes.max(4)),
|
||||
usage: buffer_usage(&source.usage)?,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
gpu.queue
|
||||
.write_buffer(&buffer, 0, data.bytes(&source.array).unwrap());
|
||||
buffers.insert(
|
||||
source.id.clone(),
|
||||
GpuBuffer {
|
||||
buffer,
|
||||
source: source.array.clone(),
|
||||
sync_each_frame: source.sync == "frame",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut textures = Vec::new();
|
||||
let mut physical_slots = HashMap::new();
|
||||
let mut texture_slots = HashMap::new();
|
||||
for source in &graph.resources.textures {
|
||||
let physical = match physical_slots.get(&source.slot) {
|
||||
Some(&physical) => physical,
|
||||
None => {
|
||||
let key = source.key()?;
|
||||
if !source.transient {
|
||||
if let Some(texture) = previous
|
||||
.and_then(|resources| {
|
||||
resources
|
||||
.texture_slots
|
||||
.get(&source.id)
|
||||
.map(|slot| &resources.textures[*slot])
|
||||
})
|
||||
.filter(|texture| texture.key == key)
|
||||
{
|
||||
let physical = textures.len();
|
||||
textures.push(texture.clone());
|
||||
physical_slots.insert(source.slot, physical);
|
||||
texture_slots.insert(source.id.clone(), physical);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let descriptor = texture_descriptor(source, gpu.width, gpu.height)?;
|
||||
let texture = gpu.device.create_texture(&descriptor);
|
||||
let physical = textures.len();
|
||||
textures.push(GpuTexture {
|
||||
view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
texture,
|
||||
key,
|
||||
uploaded_mips: HashSet::new(),
|
||||
});
|
||||
physical_slots.insert(source.slot, physical);
|
||||
physical
|
||||
}
|
||||
};
|
||||
texture_slots.insert(source.id.clone(), physical);
|
||||
}
|
||||
|
||||
let mut samplers = HashMap::new();
|
||||
for source in &graph.resources.samplers {
|
||||
samplers.insert(
|
||||
source.id.clone(),
|
||||
gpu.device
|
||||
.create_sampler(&sampler_descriptor(&source.id, &source.descriptor)?),
|
||||
);
|
||||
}
|
||||
|
||||
let render_pipelines = graph
|
||||
.pipelines
|
||||
.render
|
||||
.iter()
|
||||
.map(|source| {
|
||||
create_render_pipeline(source, gpu).map(|pipeline| (source.id.clone(), pipeline))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
let compute_pipelines = graph
|
||||
.pipelines
|
||||
.compute
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let module = gpu
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&source.id),
|
||||
source: wgpu::ShaderSource::Wgsl(source.code.clone().into()),
|
||||
});
|
||||
let pipeline =
|
||||
gpu.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(&source.id),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some(&source.entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
Ok::<_, String>((source.id.clone(), pipeline))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
|
||||
let profiler = (gpu.timestamp_queries && !graph.executions.is_empty()).then(|| {
|
||||
let query_count = graph.executions.len() as u32 * 2;
|
||||
let bytes = u64::from(query_count) * 8;
|
||||
GpuProfiler {
|
||||
query_set: gpu.device.create_query_set(&wgpu::QuerySetDescriptor {
|
||||
label: Some("frame-profile"),
|
||||
ty: wgpu::QueryType::Timestamp,
|
||||
count: query_count,
|
||||
}),
|
||||
resolve: gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("frame-profile-resolve"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
readback: Arc::new(gpu.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("frame-profile-readback"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
})),
|
||||
query_count,
|
||||
}
|
||||
});
|
||||
let mut resources = Self {
|
||||
buffers,
|
||||
textures,
|
||||
texture_slots,
|
||||
samplers,
|
||||
render_pipelines,
|
||||
compute_pipelines,
|
||||
passes: Vec::new(),
|
||||
profiler,
|
||||
};
|
||||
for execution in &graph.executions {
|
||||
let compiled = match execution {
|
||||
Execution::Render(passes) => {
|
||||
let (bundle, draws) = resources.render_bundle(graph, passes, gpu)?;
|
||||
GpuPass::Render {
|
||||
label: if passes.len() == 1 {
|
||||
graph.passes[passes[0]].id.clone()
|
||||
} else if passes
|
||||
.iter()
|
||||
.all(|index| graph.passes[*index].id.starts_with("forward-"))
|
||||
{
|
||||
format!("Forward ({draws} draws)")
|
||||
} else if passes
|
||||
.iter()
|
||||
.all(|index| graph.passes[*index].id.starts_with("depth-"))
|
||||
{
|
||||
format!("Depth ({draws} draws)")
|
||||
} else {
|
||||
format!("Render ({draws} draws)")
|
||||
},
|
||||
first: passes[0],
|
||||
last: *passes.last().unwrap(),
|
||||
bundle,
|
||||
}
|
||||
}
|
||||
Execution::Compute(index) => {
|
||||
let pass = &graph.passes[*index];
|
||||
let pipeline = resources
|
||||
.compute_pipelines
|
||||
.get(&pass.pipeline)
|
||||
.ok_or("GRAPH_PIPELINE")?
|
||||
.clone();
|
||||
let bind_groups = resources.bind_groups(
|
||||
pass,
|
||||
|group| pipeline.get_bind_group_layout(group),
|
||||
gpu,
|
||||
)?;
|
||||
GpuPass::Compute {
|
||||
label: pass.id.clone(),
|
||||
pass: *index,
|
||||
pipeline,
|
||||
bind_groups,
|
||||
}
|
||||
}
|
||||
};
|
||||
resources.passes.push(compiled);
|
||||
}
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
pub fn texture_view(&self, id: &str) -> Option<&wgpu::TextureView> {
|
||||
self.texture_slots
|
||||
.get(id)
|
||||
.and_then(|slot| self.textures.get(*slot))
|
||||
.map(|texture| &texture.view)
|
||||
}
|
||||
|
||||
pub fn upload_texture(
|
||||
&mut self,
|
||||
id: &str,
|
||||
mip_level: u32,
|
||||
image: &web_sys::ImageBitmap,
|
||||
gpu: &Wgpu,
|
||||
) -> Result<(), String> {
|
||||
let texture = self
|
||||
.texture_slots
|
||||
.get(id)
|
||||
.and_then(|slot| self.textures.get_mut(*slot))
|
||||
.ok_or("GRAPH_TEXTURE_UNKNOWN")?;
|
||||
gpu.queue.copy_external_image_to_texture(
|
||||
&wgpu::CopyExternalImageSourceInfo {
|
||||
source: wgpu::ExternalImageSource::ImageBitmap(image.clone()),
|
||||
origin: wgpu::Origin2d::ZERO,
|
||||
flip_y: false,
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture.texture,
|
||||
mip_level,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
}
|
||||
.to_tagged(wgpu::PredefinedColorSpace::Srgb, false),
|
||||
wgpu::Extent3d {
|
||||
width: image.width(),
|
||||
height: image.height(),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
texture.uploaded_mips.insert(mip_level);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn needs_upload(&self, id: &str, mip_level: u32) -> bool {
|
||||
self.texture_slots
|
||||
.get(id)
|
||||
.and_then(|slot| self.textures.get(*slot))
|
||||
.is_some_and(|texture| !texture.uploaded_mips.contains(&mip_level))
|
||||
}
|
||||
|
||||
pub fn render_timestamps(&self, pass: usize) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
|
||||
self.profiler
|
||||
.as_ref()
|
||||
.map(|profiler| wgpu::RenderPassTimestampWrites {
|
||||
query_set: &profiler.query_set,
|
||||
beginning_of_pass_write_index: Some(pass as u32 * 2),
|
||||
end_of_pass_write_index: Some(pass as u32 * 2 + 1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compute_timestamps(&self, pass: usize) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
|
||||
self.profiler
|
||||
.as_ref()
|
||||
.map(|profiler| wgpu::ComputePassTimestampWrites {
|
||||
query_set: &profiler.query_set,
|
||||
beginning_of_pass_write_index: Some(pass as u32 * 2),
|
||||
end_of_pass_write_index: Some(pass as u32 * 2 + 1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_profile(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(profiler) = &self.profiler else {
|
||||
return;
|
||||
};
|
||||
encoder.resolve_query_set(
|
||||
&profiler.query_set,
|
||||
0..profiler.query_count,
|
||||
&profiler.resolve,
|
||||
0,
|
||||
);
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&profiler.resolve,
|
||||
0,
|
||||
&profiler.readback,
|
||||
0,
|
||||
u64::from(profiler.query_count) * 8,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn map_profile(&self, timestamp_period: f32) -> Option<ProfileMap> {
|
||||
let profiler = self.profiler.as_ref()?;
|
||||
let state = Arc::new(AtomicU8::new(0));
|
||||
let callback = state.clone();
|
||||
profiler
|
||||
.readback
|
||||
.map_async(wgpu::MapMode::Read, .., move |result| {
|
||||
callback.store(if result.is_ok() { 1 } else { 2 }, Ordering::Release);
|
||||
});
|
||||
Some(ProfileMap {
|
||||
readback: profiler.readback.clone(),
|
||||
state,
|
||||
labels: self
|
||||
.passes
|
||||
.iter()
|
||||
.map(|pass| pass.label().to_owned())
|
||||
.collect(),
|
||||
timestamp_period,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileMap {
|
||||
pub fn state(&self) -> u8 {
|
||||
self.state.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn read(self) -> Vec<(String, f64)> {
|
||||
let bytes = self.readback.get_mapped_range(..);
|
||||
let values = bytes
|
||||
.chunks_exact(8)
|
||||
.map(|bytes| u64::from_le_bytes(bytes.try_into().unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
let profile = self
|
||||
.labels
|
||||
.into_iter()
|
||||
.zip(values.chunks_exact(2))
|
||||
.map(|(label, timestamps)| {
|
||||
(
|
||||
label,
|
||||
timestamps[1].saturating_sub(timestamps[0]) as f64
|
||||
* f64::from(self.timestamp_period)
|
||||
/ 1_000_000.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
drop(bytes);
|
||||
self.readback.unmap();
|
||||
profile
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuResources {
|
||||
fn render_bundle(
|
||||
&self,
|
||||
graph: &RenderGraph,
|
||||
passes: &[usize],
|
||||
gpu: &Wgpu,
|
||||
) -> Result<(wgpu::RenderBundle, usize), String> {
|
||||
let pass = &graph.passes[passes[0]];
|
||||
let declaration = graph
|
||||
.pipelines
|
||||
.render
|
||||
.iter()
|
||||
.find(|pipeline| pipeline.id == pass.pipeline)
|
||||
.ok_or("GRAPH_PIPELINE")?;
|
||||
let color_formats = pass
|
||||
.color
|
||||
.iter()
|
||||
.map(|attachment| attachment_format(graph, &attachment.resource, gpu.format).map(Some))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let depth_stencil = pass
|
||||
.depth
|
||||
.as_ref()
|
||||
.map(|attachment| {
|
||||
Ok::<_, String>(wgpu::RenderBundleDepthStencil {
|
||||
format: attachment_format(graph, &attachment.resource, gpu.format)?,
|
||||
depth_read_only: false,
|
||||
stencil_read_only: true,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let mut encoder =
|
||||
gpu.device
|
||||
.create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
|
||||
label: Some(&pass.id),
|
||||
color_formats: &color_formats,
|
||||
depth_stencil,
|
||||
sample_count: multisample(&declaration.multisample)?.count,
|
||||
multiview: None,
|
||||
});
|
||||
let mut previous_pipeline = None;
|
||||
let mut previous_bindings: Option<&[Binding]> = None;
|
||||
let mut draws = 0;
|
||||
let mut at = 0;
|
||||
while at < passes.len() {
|
||||
let pass = &graph.passes[passes[at]];
|
||||
let pipeline = self
|
||||
.render_pipelines
|
||||
.get(&pass.pipeline)
|
||||
.ok_or("GRAPH_PIPELINE")?;
|
||||
let pipeline_changed = previous_pipeline != Some(pass.pipeline.as_str());
|
||||
if pipeline_changed {
|
||||
encoder.set_pipeline(pipeline);
|
||||
previous_pipeline = Some(pass.pipeline.as_str());
|
||||
}
|
||||
if pipeline_changed || previous_bindings != Some(pass.bindings.as_slice()) {
|
||||
for (group, bind_group) in
|
||||
self.bind_groups(pass, |group| pipeline.get_bind_group_layout(group), gpu)?
|
||||
{
|
||||
encoder.set_bind_group(group, &bind_group, &[]);
|
||||
}
|
||||
previous_bindings = Some(&pass.bindings);
|
||||
}
|
||||
for binding in &pass.vertex_buffers {
|
||||
let buffer = &self
|
||||
.buffers
|
||||
.get(&binding.resource)
|
||||
.ok_or("GRAPH_RESOURCE_UNKNOWN")?
|
||||
.buffer;
|
||||
encoder.set_vertex_buffer(binding.slot, buffer.slice(binding.offset..));
|
||||
}
|
||||
if let Some(binding) = &pass.index_buffer {
|
||||
let buffer = &self
|
||||
.buffers
|
||||
.get(&binding.resource)
|
||||
.ok_or("GRAPH_RESOURCE_UNKNOWN")?
|
||||
.buffer;
|
||||
encoder.set_index_buffer(
|
||||
buffer.slice(binding.offset..),
|
||||
parse(&binding.format, "GRAPH_INDEX_FORMAT")?,
|
||||
);
|
||||
let mut instances = pass.draw.instances;
|
||||
while at + 1 < passes.len()
|
||||
&& can_instance(pass, &graph.passes[passes[at + 1]], instances)
|
||||
{
|
||||
at += 1;
|
||||
instances += graph.passes[passes[at]].draw.instances;
|
||||
}
|
||||
encoder.draw_indexed(
|
||||
pass.draw.first_index..pass.draw.first_index + pass.draw.indices,
|
||||
pass.draw.base_vertex,
|
||||
pass.draw.first_instance..pass.draw.first_instance + instances,
|
||||
);
|
||||
} else {
|
||||
encoder.draw(
|
||||
pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices,
|
||||
pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances,
|
||||
);
|
||||
}
|
||||
draws += 1;
|
||||
at += 1;
|
||||
}
|
||||
Ok((
|
||||
encoder.finish(&wgpu::RenderBundleDescriptor {
|
||||
label: Some(&pass.id),
|
||||
}),
|
||||
draws,
|
||||
))
|
||||
}
|
||||
|
||||
fn bind_groups(
|
||||
&self,
|
||||
pass: &Pass,
|
||||
layout: impl Fn(u32) -> wgpu::BindGroupLayout,
|
||||
gpu: &Wgpu,
|
||||
) -> Result<Vec<(u32, wgpu::BindGroup)>, String> {
|
||||
let mut groups: BTreeMap<u32, Vec<&Binding>> = BTreeMap::new();
|
||||
for binding in &pass.bindings {
|
||||
groups.entry(binding.group).or_default().push(binding);
|
||||
}
|
||||
groups
|
||||
.into_iter()
|
||||
.map(|(group, bindings)| {
|
||||
let entries = bindings
|
||||
.into_iter()
|
||||
.map(|binding| {
|
||||
let resource = if let Some(buffer) = self.buffers.get(&binding.resource) {
|
||||
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &buffer.buffer,
|
||||
offset: binding.offset,
|
||||
size: binding.size.and_then(NonZeroU64::new),
|
||||
})
|
||||
} else if let Some(view) = self.texture_view(&binding.resource) {
|
||||
wgpu::BindingResource::TextureView(view)
|
||||
} else if let Some(sampler) = self.samplers.get(&binding.resource) {
|
||||
wgpu::BindingResource::Sampler(sampler)
|
||||
} else {
|
||||
return Err("GRAPH_RESOURCE_UNKNOWN".into());
|
||||
};
|
||||
Ok(wgpu::BindGroupEntry {
|
||||
binding: binding.binding,
|
||||
resource,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some(&pass.id),
|
||||
layout: &layout(group),
|
||||
entries: &entries,
|
||||
});
|
||||
Ok((group, bind_group))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn can_instance(first: &Pass, next: &Pass, instances: u32) -> bool {
|
||||
first.pipeline == next.pipeline
|
||||
&& first.bindings == next.bindings
|
||||
&& first.vertex_buffers == next.vertex_buffers
|
||||
&& first.index_buffer == next.index_buffer
|
||||
&& first.draw.indices != 0
|
||||
&& first.draw.indices == next.draw.indices
|
||||
&& first.draw.first_index == next.draw.first_index
|
||||
&& first.draw.base_vertex == next.draw.base_vertex
|
||||
&& first.draw.first_instance + instances == next.draw.first_instance
|
||||
}
|
||||
|
||||
impl GpuPass {
|
||||
fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Render { label, .. } | Self::Compute { label, .. } => label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_render_pipeline(
|
||||
source: &RenderPipeline,
|
||||
gpu: &Wgpu,
|
||||
) -> Result<wgpu::RenderPipeline, String> {
|
||||
let module = gpu
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&source.id),
|
||||
source: wgpu::ShaderSource::Wgsl(source.code.clone().into()),
|
||||
});
|
||||
let attributes = source
|
||||
.vertex
|
||||
.buffers
|
||||
.iter()
|
||||
.map(|buffer| {
|
||||
buffer
|
||||
.attributes
|
||||
.iter()
|
||||
.map(|attribute| {
|
||||
Ok(wgpu::VertexAttribute {
|
||||
format: parse(&attribute.format, "GRAPH_VERTEX_FORMAT")?,
|
||||
offset: attribute.offset,
|
||||
shader_location: attribute.shader_location,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let layouts = source
|
||||
.vertex
|
||||
.buffers
|
||||
.iter()
|
||||
.zip(&attributes)
|
||||
.map(|(buffer, attributes)| {
|
||||
Ok(wgpu::VertexBufferLayout {
|
||||
array_stride: buffer.array_stride,
|
||||
step_mode: parse(&buffer.step_mode, "GRAPH_VERTEX_STEP")?,
|
||||
attributes,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
let targets = source
|
||||
.fragment
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let format = if target.format == "canvas" {
|
||||
gpu.format
|
||||
} else {
|
||||
parse(&target.format, "GRAPH_TEXTURE_FORMAT")?
|
||||
};
|
||||
let blend = (!target.blend.is_null())
|
||||
.then(|| {
|
||||
serde_json::from_value::<wgpu::BlendState>(target.blend.clone())
|
||||
.map_err(|_| String::from("GRAPH_BLEND"))
|
||||
})
|
||||
.transpose()?;
|
||||
let write_mask = match target.write_mask {
|
||||
Some(bits) => wgpu::ColorWrites::from_bits(bits).ok_or("GRAPH_WRITE_MASK")?,
|
||||
None => wgpu::ColorWrites::ALL,
|
||||
};
|
||||
Ok(Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask,
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
Ok(gpu
|
||||
.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&source.id),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(&source.vertex.entry),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &layouts,
|
||||
},
|
||||
primitive: primitive(&source.primitive)?,
|
||||
depth_stencil: depth_stencil(&source.depth_stencil)?,
|
||||
multisample: multisample(&source.multisample)?,
|
||||
fragment: (!targets.is_empty()).then_some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(&source.fragment.entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &targets,
|
||||
}),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn buffer_usage(names: &[String]) -> Result<wgpu::BufferUsages, String> {
|
||||
names
|
||||
.iter()
|
||||
.try_fold(wgpu::BufferUsages::COPY_DST, |usage, name| {
|
||||
Ok(usage
|
||||
| match name.as_str() {
|
||||
"uniform" => wgpu::BufferUsages::UNIFORM,
|
||||
"storage" => wgpu::BufferUsages::STORAGE,
|
||||
"vertex" => wgpu::BufferUsages::VERTEX,
|
||||
"index" => wgpu::BufferUsages::INDEX,
|
||||
"indirect" => wgpu::BufferUsages::INDIRECT,
|
||||
"copySrc" => wgpu::BufferUsages::COPY_SRC,
|
||||
_ => return Err("GRAPH_BUFFER_USAGE".into()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn texture_usage(names: &[String]) -> Result<wgpu::TextureUsages, String> {
|
||||
names
|
||||
.iter()
|
||||
.try_fold(wgpu::TextureUsages::empty(), |usage, name| {
|
||||
Ok(usage
|
||||
| match name.as_str() {
|
||||
"render" => wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
"sampled" => wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
"storage" => wgpu::TextureUsages::STORAGE_BINDING,
|
||||
"copySrc" => wgpu::TextureUsages::COPY_SRC,
|
||||
"copyDst" => wgpu::TextureUsages::COPY_DST,
|
||||
_ => return Err("GRAPH_TEXTURE_USAGE".into()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn texture_descriptor(
|
||||
source: &Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<wgpu::TextureDescriptor<'_>, String> {
|
||||
let value = |index, canvas| -> Result<u32, String> {
|
||||
match source.size.get(index) {
|
||||
Some(Extent::Pixels(value)) => Ok(*value),
|
||||
Some(Extent::Canvas(value)) if value == "canvas" => Ok(canvas),
|
||||
Some(Extent::Canvas(_)) => Err("GRAPH_TEXTURE_SIZE".into()),
|
||||
None => Ok(canvas),
|
||||
}
|
||||
};
|
||||
Ok(wgpu::TextureDescriptor {
|
||||
label: Some(&source.id),
|
||||
size: wgpu::Extent3d {
|
||||
width: value(0, width)?,
|
||||
height: value(1, height)?,
|
||||
depth_or_array_layers: value(2, 1)?,
|
||||
},
|
||||
mip_level_count: source.mip_level_count,
|
||||
sample_count: source.sample_count,
|
||||
dimension: parse(&source.dimension, "GRAPH_TEXTURE_DIMENSION")?,
|
||||
format: parse(&source.format, "GRAPH_TEXTURE_FORMAT")?,
|
||||
usage: texture_usage(&source.usage)?,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn attachment_format(
|
||||
graph: &RenderGraph,
|
||||
id: &str,
|
||||
surface: wgpu::TextureFormat,
|
||||
) -> Result<wgpu::TextureFormat, String> {
|
||||
if id == "canvas" {
|
||||
return Ok(surface);
|
||||
}
|
||||
graph
|
||||
.resources
|
||||
.textures
|
||||
.iter()
|
||||
.find(|texture| texture.id == id)
|
||||
.ok_or_else(|| "GRAPH_ATTACHMENT".into())
|
||||
.and_then(|texture| parse(&texture.format, "GRAPH_TEXTURE_FORMAT"))
|
||||
}
|
||||
|
||||
fn primitive(value: &Value) -> Result<wgpu::PrimitiveState, String> {
|
||||
if value.is_null() {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
serde_json::from_value(value.clone()).map_err(|_| "GRAPH_PRIMITIVE".into())
|
||||
}
|
||||
|
||||
fn depth_stencil(value: &Value) -> Result<Option<wgpu::DepthStencilState>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_value(value.clone())
|
||||
.map(Some)
|
||||
.map_err(|_| "GRAPH_DEPTH_STENCIL".into())
|
||||
}
|
||||
|
||||
fn multisample(value: &Value) -> Result<wgpu::MultisampleState, String> {
|
||||
if value.is_null() {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
let object = value.as_object().ok_or("GRAPH_MULTISAMPLE")?;
|
||||
Ok(wgpu::MultisampleState {
|
||||
count: object.get("count").and_then(Value::as_u64).unwrap_or(1) as u32,
|
||||
mask: object
|
||||
.get("mask")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(u64::MAX),
|
||||
alpha_to_coverage_enabled: object
|
||||
.get("alphaToCoverageEnabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn sampler_descriptor<'a>(
|
||||
label: &'a str,
|
||||
value: &Value,
|
||||
) -> Result<wgpu::SamplerDescriptor<'a>, String> {
|
||||
let mut descriptor = wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
..Default::default()
|
||||
};
|
||||
let Some(object) = value.as_object() else {
|
||||
return Ok(descriptor);
|
||||
};
|
||||
macro_rules! enum_field {
|
||||
($json:literal, $field:ident, $code:literal) => {
|
||||
if let Some(value) = object.get($json).and_then(Value::as_str) {
|
||||
descriptor.$field = parse(value, $code)?;
|
||||
}
|
||||
};
|
||||
}
|
||||
enum_field!("addressModeU", address_mode_u, "GRAPH_SAMPLER");
|
||||
enum_field!("addressModeV", address_mode_v, "GRAPH_SAMPLER");
|
||||
enum_field!("addressModeW", address_mode_w, "GRAPH_SAMPLER");
|
||||
enum_field!("magFilter", mag_filter, "GRAPH_SAMPLER");
|
||||
enum_field!("minFilter", min_filter, "GRAPH_SAMPLER");
|
||||
enum_field!("mipmapFilter", mipmap_filter, "GRAPH_SAMPLER");
|
||||
descriptor.lod_min_clamp = object
|
||||
.get("lodMinClamp")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0) as f32;
|
||||
descriptor.lod_max_clamp = object
|
||||
.get("lodMaxClamp")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(32.0) as f32;
|
||||
descriptor.compare = object
|
||||
.get("compare")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| parse(value, "GRAPH_SAMPLER"))
|
||||
.transpose()?;
|
||||
descriptor.anisotropy_clamp = object
|
||||
.get("anisotropyClamp")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1) as u16;
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
fn parse<T: DeserializeOwned>(value: &str, code: &str) -> Result<T, String> {
|
||||
serde_json::from_value(Value::String(value.into())).map_err(|_| code.into())
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenderGraph {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub resources: ResourceDeclarations,
|
||||
#[serde(default)]
|
||||
pub pipelines: PipelineDeclarations,
|
||||
pub passes: Vec<Pass>,
|
||||
#[serde(skip)]
|
||||
pub executions: Vec<Execution>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Execution {
|
||||
Compute(usize),
|
||||
Render(Vec<usize>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct ResourceDeclarations {
|
||||
#[serde(default)]
|
||||
pub buffers: Vec<Buffer>,
|
||||
#[serde(default)]
|
||||
pub textures: Vec<Texture>,
|
||||
#[serde(default)]
|
||||
pub samplers: Vec<Sampler>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Buffer {
|
||||
pub id: String,
|
||||
pub array: String,
|
||||
#[serde(default)]
|
||||
pub usage: Vec<String>,
|
||||
#[serde(default = "frame_sync")]
|
||||
pub sync: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Texture {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub size: Vec<Extent>,
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub usage: Vec<String>,
|
||||
#[serde(default = "one")]
|
||||
pub mip_level_count: u32,
|
||||
#[serde(default = "one")]
|
||||
pub sample_count: u32,
|
||||
#[serde(default = "dimension")]
|
||||
pub dimension: String,
|
||||
#[serde(default = "yes")]
|
||||
pub transient: bool,
|
||||
#[serde(skip)]
|
||||
pub slot: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Extent {
|
||||
Pixels(u32),
|
||||
Canvas(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Sampler {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub descriptor: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
pub struct PipelineDeclarations {
|
||||
#[serde(default)]
|
||||
pub render: Vec<RenderPipeline>,
|
||||
#[serde(default)]
|
||||
pub compute: Vec<ComputePipeline>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenderPipeline {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub vertex: VertexStage,
|
||||
#[serde(default)]
|
||||
pub fragment: FragmentStage,
|
||||
#[serde(default)]
|
||||
pub primitive: Value,
|
||||
#[serde(default)]
|
||||
pub depth_stencil: Value,
|
||||
#[serde(default)]
|
||||
pub multisample: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ComputePipeline {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
#[serde(default = "compute_entry")]
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct VertexStage {
|
||||
#[serde(default = "vertex_entry")]
|
||||
pub entry: String,
|
||||
#[serde(default)]
|
||||
pub buffers: Vec<VertexBuffer>,
|
||||
}
|
||||
|
||||
impl Default for VertexStage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entry: vertex_entry(),
|
||||
buffers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VertexBuffer {
|
||||
pub array_stride: u64,
|
||||
#[serde(default = "vertex_step")]
|
||||
pub step_mode: String,
|
||||
#[serde(default)]
|
||||
pub attributes: Vec<VertexAttribute>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VertexAttribute {
|
||||
pub format: String,
|
||||
pub offset: u64,
|
||||
pub shader_location: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct FragmentStage {
|
||||
#[serde(default = "fragment_entry")]
|
||||
pub entry: String,
|
||||
#[serde(default)]
|
||||
pub targets: Vec<FragmentTarget>,
|
||||
}
|
||||
|
||||
impl Default for FragmentStage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entry: fragment_entry(),
|
||||
targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FragmentTarget {
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub blend: Value,
|
||||
pub write_mask: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Pass {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub pipeline: String,
|
||||
#[serde(default)]
|
||||
pub after: Vec<String>,
|
||||
#[serde(skip)]
|
||||
pub dependencies: Vec<usize>,
|
||||
#[serde(default)]
|
||||
pub bindings: Vec<Binding>,
|
||||
#[serde(default)]
|
||||
pub color: Vec<ColorAttachment>,
|
||||
pub depth: Option<DepthAttachment>,
|
||||
#[serde(default)]
|
||||
pub vertex_buffers: Vec<VertexBinding>,
|
||||
pub index_buffer: Option<IndexBinding>,
|
||||
#[serde(default)]
|
||||
pub draw: Draw,
|
||||
#[serde(default = "dispatch")]
|
||||
pub dispatch: [u32; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct Binding {
|
||||
#[serde(default)]
|
||||
pub group: u32,
|
||||
pub binding: u32,
|
||||
pub resource: String,
|
||||
#[serde(default)]
|
||||
pub offset: u64,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ColorAttachment {
|
||||
pub resource: String,
|
||||
#[serde(default)]
|
||||
pub clear: Vec<f32>,
|
||||
#[serde(default = "clear_op")]
|
||||
pub load: String,
|
||||
#[serde(default = "store_op")]
|
||||
pub store: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct DepthAttachment {
|
||||
pub resource: String,
|
||||
#[serde(default = "one_f32")]
|
||||
pub clear: f32,
|
||||
#[serde(default = "clear_op")]
|
||||
pub load: String,
|
||||
#[serde(default = "store_op")]
|
||||
pub store: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct VertexBinding {
|
||||
#[serde(default)]
|
||||
pub slot: u32,
|
||||
pub resource: String,
|
||||
#[serde(default)]
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct IndexBinding {
|
||||
pub resource: String,
|
||||
#[serde(default = "index_format")]
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Draw {
|
||||
#[serde(default = "three")]
|
||||
pub vertices: u32,
|
||||
#[serde(default)]
|
||||
pub indices: u32,
|
||||
#[serde(default = "one")]
|
||||
pub instances: u32,
|
||||
#[serde(default)]
|
||||
pub first_vertex: u32,
|
||||
#[serde(default)]
|
||||
pub first_index: u32,
|
||||
#[serde(default)]
|
||||
pub base_vertex: i32,
|
||||
#[serde(default)]
|
||||
pub first_instance: u32,
|
||||
}
|
||||
|
||||
impl Default for Draw {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vertices: 3,
|
||||
indices: 0,
|
||||
instances: 1,
|
||||
first_vertex: 0,
|
||||
first_index: 0,
|
||||
base_vertex: 0,
|
||||
first_instance: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderGraph {
|
||||
pub fn prepare(&mut self) -> Result<(), &'static str> {
|
||||
if self.id.is_empty() || self.passes.is_empty() {
|
||||
return Err("GRAPH_SHAPE");
|
||||
}
|
||||
let mut ids = HashMap::new();
|
||||
for (index, pass) in self.passes.iter().enumerate() {
|
||||
if pass.id.is_empty() || ids.insert(pass.id.clone(), index).is_some() {
|
||||
return Err("GRAPH_PASS");
|
||||
}
|
||||
}
|
||||
let dependencies = self
|
||||
.passes
|
||||
.iter()
|
||||
.map(|pass| {
|
||||
pass.after
|
||||
.iter()
|
||||
.map(|id| ids.get(id).copied().ok_or("GRAPH_DEPENDENCY"))
|
||||
.collect::<Result<HashSet<_>, _>>()
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut emitted = vec![false; self.passes.len()];
|
||||
let mut order = Vec::with_capacity(self.passes.len());
|
||||
while order.len() < self.passes.len() {
|
||||
let ready = (0..self.passes.len()).find(|&index| {
|
||||
!emitted[index]
|
||||
&& dependencies[index]
|
||||
.iter()
|
||||
.all(|dependency| emitted[*dependency])
|
||||
});
|
||||
let index = ready.ok_or("GRAPH_CYCLE")?;
|
||||
emitted[index] = true;
|
||||
order.push(index);
|
||||
}
|
||||
let original = self.passes.clone();
|
||||
self.passes = order
|
||||
.into_iter()
|
||||
.map(|index| original[index].clone())
|
||||
.collect();
|
||||
let sorted_ids: HashMap<_, _> = self
|
||||
.passes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, pass)| (pass.id.clone(), index))
|
||||
.collect();
|
||||
for pass in &mut self.passes {
|
||||
pass.dependencies = pass.after.iter().map(|id| sorted_ids[id]).collect();
|
||||
}
|
||||
self.plan_resources()?;
|
||||
self.plan_execution();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plan_resources(&mut self) -> Result<(), &'static str> {
|
||||
let mut used = HashSet::new();
|
||||
let mut lifetimes = HashMap::new();
|
||||
let mut render_pipelines = HashSet::new();
|
||||
let mut compute_pipelines = HashSet::new();
|
||||
for (frame, pass) in self.passes.iter().enumerate() {
|
||||
match pass.kind.as_str() {
|
||||
"render" => _ = render_pipelines.insert(pass.pipeline.clone()),
|
||||
"compute" => _ = compute_pipelines.insert(pass.pipeline.clone()),
|
||||
_ => return Err("GRAPH_PASS"),
|
||||
}
|
||||
for id in pass.resources() {
|
||||
used.insert(id.to_owned());
|
||||
lifetimes
|
||||
.entry(id.to_owned())
|
||||
.and_modify(|range: &mut (usize, usize)| range.1 = frame)
|
||||
.or_insert((frame, frame));
|
||||
}
|
||||
}
|
||||
self.resources
|
||||
.buffers
|
||||
.retain(|value| used.contains(&value.id));
|
||||
self.resources
|
||||
.samplers
|
||||
.retain(|value| used.contains(&value.id));
|
||||
self.pipelines
|
||||
.render
|
||||
.retain(|value| render_pipelines.contains(&value.id));
|
||||
self.pipelines
|
||||
.compute
|
||||
.retain(|value| compute_pipelines.contains(&value.id));
|
||||
|
||||
let mut textures = self
|
||||
.resources
|
||||
.textures
|
||||
.drain(..)
|
||||
.filter_map(|texture| {
|
||||
lifetimes
|
||||
.get(&texture.id)
|
||||
.copied()
|
||||
.map(|range| (texture, range))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
textures.sort_by_key(|value| value.1 .0);
|
||||
let mut slots: Vec<(String, usize, bool)> = Vec::new();
|
||||
for (texture, (first, last)) in &mut textures {
|
||||
let key = texture.key()?;
|
||||
let reusable = texture
|
||||
.transient
|
||||
.then(|| {
|
||||
slots
|
||||
.iter()
|
||||
.position(|slot| slot.2 && slot.0 == key && slot.1 < *first)
|
||||
})
|
||||
.flatten();
|
||||
texture.slot = match reusable {
|
||||
Some(slot) => {
|
||||
slots[slot].1 = *last;
|
||||
slot
|
||||
}
|
||||
None => {
|
||||
slots.push((key, *last, texture.transient));
|
||||
slots.len() - 1
|
||||
}
|
||||
};
|
||||
}
|
||||
self.resources.textures = textures.into_iter().map(|value| value.0).collect();
|
||||
self.validate_ids()
|
||||
}
|
||||
|
||||
fn plan_execution(&mut self) {
|
||||
let mut executions = Vec::new();
|
||||
for index in 0..self.passes.len() {
|
||||
let merge = executions.last().is_some_and(|execution| match execution {
|
||||
Execution::Render(passes) => self.can_merge_render(*passes.last().unwrap(), index),
|
||||
Execution::Compute(_) => false,
|
||||
});
|
||||
if merge {
|
||||
let Some(Execution::Render(passes)) = executions.last_mut() else {
|
||||
unreachable!()
|
||||
};
|
||||
passes.push(index);
|
||||
} else if self.passes[index].kind == "render" {
|
||||
executions.push(Execution::Render(vec![index]));
|
||||
} else {
|
||||
executions.push(Execution::Compute(index));
|
||||
}
|
||||
}
|
||||
self.executions = executions;
|
||||
}
|
||||
|
||||
fn can_merge_render(&self, previous: usize, next: usize) -> bool {
|
||||
let previous = &self.passes[previous];
|
||||
let next = &self.passes[next];
|
||||
if next.kind != "render"
|
||||
|| previous.color.len() != next.color.len()
|
||||
|| self.sample_count(&previous.pipeline) != self.sample_count(&next.pipeline)
|
||||
|| previous
|
||||
.color
|
||||
.iter()
|
||||
.zip(&next.color)
|
||||
.any(|(previous, next)| {
|
||||
previous.resource != next.resource
|
||||
|| previous.store != "store"
|
||||
|| next.load != "load"
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let same_depth = match (&previous.depth, &next.depth) {
|
||||
(None, None) => true,
|
||||
(Some(previous), Some(next)) => {
|
||||
previous.resource == next.resource
|
||||
&& previous.store == "store"
|
||||
&& next.load == "load"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
same_depth
|
||||
&& !next.bindings.iter().any(|binding| {
|
||||
next.color
|
||||
.iter()
|
||||
.any(|attachment| attachment.resource == binding.resource)
|
||||
|| next
|
||||
.depth
|
||||
.as_ref()
|
||||
.is_some_and(|attachment| attachment.resource == binding.resource)
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_count(&self, pipeline: &str) -> Option<u64> {
|
||||
self.pipelines
|
||||
.render
|
||||
.iter()
|
||||
.find(|value| value.id == pipeline)
|
||||
.map(|value| {
|
||||
value
|
||||
.multisample
|
||||
.get("count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ids(&self) -> Result<(), &'static str> {
|
||||
let mut resources = HashSet::new();
|
||||
for id in self
|
||||
.resources
|
||||
.buffers
|
||||
.iter()
|
||||
.map(|value| &value.id)
|
||||
.chain(self.resources.textures.iter().map(|value| &value.id))
|
||||
.chain(self.resources.samplers.iter().map(|value| &value.id))
|
||||
{
|
||||
if id.is_empty() || !resources.insert(id) {
|
||||
return Err("GRAPH_RESOURCE");
|
||||
}
|
||||
}
|
||||
if self
|
||||
.resources
|
||||
.buffers
|
||||
.iter()
|
||||
.any(|buffer| !matches!(buffer.sync.as_str(), "frame" | "loadout"))
|
||||
{
|
||||
return Err("GRAPH_BUFFER_SYNC");
|
||||
}
|
||||
let mut pipelines = HashSet::new();
|
||||
for id in self
|
||||
.pipelines
|
||||
.render
|
||||
.iter()
|
||||
.map(|value| &value.id)
|
||||
.chain(self.pipelines.compute.iter().map(|value| &value.id))
|
||||
{
|
||||
if id.is_empty() || !pipelines.insert(id) {
|
||||
return Err("GRAPH_PIPELINE");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Pass {
|
||||
fn resources(&self) -> Vec<&str> {
|
||||
self.bindings
|
||||
.iter()
|
||||
.map(|value| value.resource.as_str())
|
||||
.chain(self.color.iter().map(|value| value.resource.as_str()))
|
||||
.chain(self.depth.iter().map(|value| value.resource.as_str()))
|
||||
.chain(
|
||||
self.vertex_buffers
|
||||
.iter()
|
||||
.map(|value| value.resource.as_str()),
|
||||
)
|
||||
.chain(
|
||||
self.index_buffer
|
||||
.iter()
|
||||
.map(|value| value.resource.as_str()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Texture {
|
||||
pub(crate) fn key(&self) -> Result<String, &'static str> {
|
||||
let mut usage = self.usage.clone();
|
||||
usage.sort_unstable();
|
||||
usage.dedup();
|
||||
serde_json::to_string(&(
|
||||
&self.size,
|
||||
&self.format,
|
||||
usage,
|
||||
self.mip_level_count,
|
||||
self.sample_count,
|
||||
&self.dimension,
|
||||
))
|
||||
.map_err(|_| "GRAPH_RESOURCE")
|
||||
}
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
fn three() -> u32 {
|
||||
3
|
||||
}
|
||||
fn one_f32() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn dimension() -> String {
|
||||
"2d".into()
|
||||
}
|
||||
fn vertex_entry() -> String {
|
||||
"vertex".into()
|
||||
}
|
||||
fn fragment_entry() -> String {
|
||||
"fragment".into()
|
||||
}
|
||||
fn compute_entry() -> String {
|
||||
"main".into()
|
||||
}
|
||||
fn vertex_step() -> String {
|
||||
"vertex".into()
|
||||
}
|
||||
fn clear_op() -> String {
|
||||
"clear".into()
|
||||
}
|
||||
fn store_op() -> String {
|
||||
"store".into()
|
||||
}
|
||||
fn index_format() -> String {
|
||||
"uint32".into()
|
||||
}
|
||||
fn dispatch() -> [u32; 3] {
|
||||
[1, 1, 1]
|
||||
}
|
||||
fn frame_sync() -> String {
|
||||
"frame".into()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::gpu::Wgpu;
|
||||
use crate::gpu_resource::GpuResources;
|
||||
use crate::graph::RenderGraph;
|
||||
use crate::render_data::RenderData;
|
||||
|
||||
pub struct Loadout {
|
||||
pub graph: RenderGraph,
|
||||
pub resources: GpuResources,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
graphs: HashMap<String, RenderGraph>,
|
||||
texture_sources: HashMap<String, HashMap<u32, web_sys::ImageBitmap>>,
|
||||
active: Option<Loadout>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn save(&mut self, graph: RenderGraph) -> String {
|
||||
let id = graph.id.clone();
|
||||
self.graphs.insert(id.clone(), graph);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> {
|
||||
let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone();
|
||||
let mut resources = GpuResources::activate(
|
||||
&graph,
|
||||
gpu,
|
||||
data,
|
||||
self.active.as_ref().map(|active| &active.resources),
|
||||
)?;
|
||||
for (name, levels) in &self.texture_sources {
|
||||
for (mip_level, image) in levels {
|
||||
if resources.needs_upload(name, *mip_level) {
|
||||
resources.upload_texture(name, *mip_level, image, gpu)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active = Some(Loadout { graph, resources });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upload_texture(
|
||||
&mut self,
|
||||
name: String,
|
||||
mip_level: u32,
|
||||
image: web_sys::ImageBitmap,
|
||||
gpu: &Wgpu,
|
||||
) -> Result<(), String> {
|
||||
if let Some(active) = &mut self.active {
|
||||
if active.resources.texture_slots.contains_key(&name) {
|
||||
active
|
||||
.resources
|
||||
.upload_texture(&name, mip_level, &image, gpu)?;
|
||||
}
|
||||
}
|
||||
let levels = self.texture_sources.entry(name).or_default();
|
||||
if let Some(previous) = levels.insert(mip_level, image) {
|
||||
previous.close();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_texture(&mut self, name: &str) {
|
||||
if let Some(levels) = self.texture_sources.remove(name) {
|
||||
for image in levels.into_values() {
|
||||
image.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_mut(&mut self) -> Option<&mut Loadout> {
|
||||
self.active.as_mut()
|
||||
}
|
||||
|
||||
pub fn uses_rows(&self, name: &str) -> bool {
|
||||
self.active.as_ref().is_some_and(|active| {
|
||||
active
|
||||
.graph
|
||||
.resources
|
||||
.buffers
|
||||
.iter()
|
||||
.any(|buffer| buffer.array == name)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_rows(
|
||||
&mut self,
|
||||
name: &str,
|
||||
gpu: &Wgpu,
|
||||
data: &RenderData,
|
||||
) -> Result<(), String> {
|
||||
if !self.uses_rows(name) {
|
||||
return Ok(());
|
||||
}
|
||||
self.refresh(gpu, data)
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, gpu: &Wgpu, data: &RenderData) -> Result<(), String> {
|
||||
let Some(active) = self.active.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let graph = active.graph.clone();
|
||||
let mut resources = GpuResources::activate(
|
||||
&graph,
|
||||
gpu,
|
||||
data,
|
||||
self.active.as_ref().map(|active| &active.resources),
|
||||
)?;
|
||||
for (name, levels) in &self.texture_sources {
|
||||
for (mip_level, image) in levels {
|
||||
if resources.needs_upload(name, *mip_level) {
|
||||
resources.upload_texture(name, *mip_level, image, gpu)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.active = Some(Loadout { graph, resources });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
use crate::gpu::Wgpu;
|
||||
use crate::gpu_resource::{GpuPass, ProfileMap};
|
||||
use crate::graph::{ColorAttachment, DepthAttachment};
|
||||
use crate::render_data::RenderData;
|
||||
use crate::store::{Loadout, Store};
|
||||
|
||||
#[wasm_bindgen(inline_js = r#"
|
||||
const channel = new MessageChannel();
|
||||
const ready = [];
|
||||
channel.port1.onmessage = () => ready.shift()?.();
|
||||
|
||||
export function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
export function yieldTask() {
|
||||
return new Promise(resolve => {
|
||||
ready.push(resolve);
|
||||
channel.port2.postMessage(0);
|
||||
});
|
||||
}
|
||||
"#)]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = nextFrame)]
|
||||
async fn next_frame();
|
||||
#[wasm_bindgen(js_name = yieldTask)]
|
||||
async fn yield_task();
|
||||
}
|
||||
|
||||
pub struct RenderLoop {
|
||||
playing: Cell<bool>,
|
||||
fps: Cell<u32>,
|
||||
started: Cell<bool>,
|
||||
frame: Cell<u32>,
|
||||
elapsed: Cell<f64>,
|
||||
last: Cell<f64>,
|
||||
profiling: Cell<bool>,
|
||||
profile_pending: Cell<bool>,
|
||||
profile_after: Cell<f64>,
|
||||
profile: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
impl RenderLoop {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
playing: Cell::new(true),
|
||||
fps: Cell::new(0),
|
||||
started: Cell::new(false),
|
||||
frame: Cell::new(0),
|
||||
elapsed: Cell::new(0.0),
|
||||
last: Cell::new(js_sys::Date::now()),
|
||||
profiling: Cell::new(false),
|
||||
profile_pending: Cell::new(false),
|
||||
profile_after: Cell::new(0.0),
|
||||
profile: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play(&self) {
|
||||
self.last.set(js_sys::Date::now());
|
||||
self.playing.set(true);
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
self.playing.set(false);
|
||||
}
|
||||
|
||||
pub fn set_fps(&self, fps: u32) -> Result<(), &'static str> {
|
||||
if fps > 1000 {
|
||||
return Err("FPS");
|
||||
}
|
||||
self.fps.set(fps);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_profiling(&self, enabled: bool) {
|
||||
self.profiling.set(enabled);
|
||||
self.profile_after.set(0.0);
|
||||
if !enabled {
|
||||
self.profile.borrow_mut().take();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_profile(&self) -> Option<String> {
|
||||
self.profile.borrow_mut().take()
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
self: &Rc<Self>,
|
||||
gpu: Rc<RefCell<Option<Wgpu>>>,
|
||||
store: Rc<RefCell<Store>>,
|
||||
data: Rc<RefCell<RenderData>>,
|
||||
) {
|
||||
if self.started.replace(true) {
|
||||
return;
|
||||
}
|
||||
let control = self.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
next_frame().await;
|
||||
if !control.playing.get() {
|
||||
continue;
|
||||
}
|
||||
let started = js_sys::Date::now();
|
||||
let fps = control.fps.get();
|
||||
if fps != 0 && started - control.last.get() < 900.0 / f64::from(fps) {
|
||||
continue;
|
||||
}
|
||||
let delta = (started - control.last.replace(started)) / 1000.0;
|
||||
let elapsed = control.elapsed.get() + delta;
|
||||
control.elapsed.set(elapsed);
|
||||
let frame = control.frame.get().wrapping_add(1);
|
||||
control.frame.set(frame);
|
||||
{
|
||||
let mut data = data.borrow_mut();
|
||||
data.update_signals(delta as f32, frame, elapsed as f32, control.fps.get());
|
||||
}
|
||||
let submission = if data.borrow().render_pending() {
|
||||
if let (Some(gpu), Some(loadout)) =
|
||||
(gpu.borrow_mut().as_mut(), store.borrow_mut().active_mut())
|
||||
{
|
||||
if data.borrow_mut().begin_render() {
|
||||
let profile = control.profiling.get()
|
||||
&& !control.profile_pending.get()
|
||||
&& started >= control.profile_after.get();
|
||||
let result = {
|
||||
let data = data.borrow();
|
||||
gpu.render(loadout, &data, profile)
|
||||
};
|
||||
match result {
|
||||
Ok(profile) => profile.map(|profile| {
|
||||
(profile, gpu.adapter.clone(), gpu.width, gpu.height)
|
||||
}),
|
||||
Err(_) => {
|
||||
data.borrow_mut().mark_dirty();
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((profile, adapter, width, height)) = submission {
|
||||
control.profile_pending.set(true);
|
||||
control.profile_after.set(started + 250.0);
|
||||
let control = control.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
while profile.state() == 0 {
|
||||
yield_task().await;
|
||||
}
|
||||
if profile.state() == 1 {
|
||||
let passes = profile
|
||||
.read()
|
||||
.into_iter()
|
||||
.map(|(name, milliseconds)| {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"milliseconds": milliseconds,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let milliseconds = passes
|
||||
.iter()
|
||||
.filter_map(|pass| pass["milliseconds"].as_f64())
|
||||
.sum::<f64>();
|
||||
if control.profiling.get() {
|
||||
*control.profile.borrow_mut() = Some(
|
||||
serde_json::json!({
|
||||
"frame": frame,
|
||||
"milliseconds": milliseconds,
|
||||
"readbackMilliseconds": js_sys::Date::now() - started,
|
||||
"adapter": adapter,
|
||||
"canvas": { "width": width, "height": height },
|
||||
"passes": passes,
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
control.profile_pending.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Wgpu {
|
||||
fn render(
|
||||
&mut self,
|
||||
loadout: &mut Loadout,
|
||||
data: &RenderData,
|
||||
profile: bool,
|
||||
) -> Result<Option<ProfileMap>, String> {
|
||||
for buffer in loadout.resources.buffers.values() {
|
||||
if !buffer.sync_each_frame {
|
||||
continue;
|
||||
}
|
||||
self.queue.write_buffer(
|
||||
&buffer.buffer,
|
||||
0,
|
||||
data.bytes(&buffer.source).ok_or("ROWS_UNKNOWN")?,
|
||||
);
|
||||
}
|
||||
let output = match self.surface.get_current_texture() {
|
||||
Ok(output) => output,
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.surface.get_current_texture().map_err(|_| "SURFACE")?
|
||||
}
|
||||
Err(wgpu::SurfaceError::Timeout) => return Err("SURFACE".into()),
|
||||
Err(_) => return Err("SURFACE".into()),
|
||||
};
|
||||
let surface_view = output
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("frame"),
|
||||
});
|
||||
for (pass_index, compiled) in loadout.resources.passes.iter().enumerate() {
|
||||
match compiled {
|
||||
GpuPass::Compute {
|
||||
pass,
|
||||
pipeline,
|
||||
bind_groups,
|
||||
..
|
||||
} => {
|
||||
let pass = &loadout.graph.passes[*pass];
|
||||
let timestamp_writes = profile
|
||||
.then(|| loadout.resources.compute_timestamps(pass_index))
|
||||
.flatten();
|
||||
let mut command = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some(&pass.id),
|
||||
timestamp_writes,
|
||||
});
|
||||
command.set_pipeline(pipeline);
|
||||
for (group, bind_group) in bind_groups {
|
||||
command.set_bind_group(*group, bind_group, &[]);
|
||||
}
|
||||
command.dispatch_workgroups(
|
||||
pass.dispatch[0],
|
||||
pass.dispatch[1],
|
||||
pass.dispatch[2],
|
||||
);
|
||||
}
|
||||
GpuPass::Render {
|
||||
first,
|
||||
last,
|
||||
bundle,
|
||||
..
|
||||
} => {
|
||||
let pass = &loadout.graph.passes[*first];
|
||||
let last = &loadout.graph.passes[*last];
|
||||
let colors = pass
|
||||
.color
|
||||
.iter()
|
||||
.zip(&last.color)
|
||||
.map(|(attachment, final_attachment)| {
|
||||
Ok(Some(wgpu::RenderPassColorAttachment {
|
||||
view: view(
|
||||
&loadout.resources,
|
||||
&surface_view,
|
||||
&attachment.resource,
|
||||
)?,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: color_ops(attachment, final_attachment)?,
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
let depth = pass
|
||||
.depth
|
||||
.as_ref()
|
||||
.zip(last.depth.as_ref())
|
||||
.map(|(attachment, final_attachment)| {
|
||||
Ok::<_, String>(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: view(
|
||||
&loadout.resources,
|
||||
&surface_view,
|
||||
&attachment.resource,
|
||||
)?,
|
||||
depth_ops: Some(depth_ops(attachment, final_attachment)?),
|
||||
stencil_ops: None,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let timestamp_writes = profile
|
||||
.then(|| loadout.resources.render_timestamps(pass_index))
|
||||
.flatten();
|
||||
let mut command = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(&pass.id),
|
||||
color_attachments: &colors,
|
||||
depth_stencil_attachment: depth,
|
||||
timestamp_writes,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
command.execute_bundles([bundle]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if profile {
|
||||
loadout.resources.resolve_profile(&mut encoder);
|
||||
}
|
||||
self.queue.submit([encoder.finish()]);
|
||||
let profile = profile
|
||||
.then(|| {
|
||||
loadout
|
||||
.resources
|
||||
.map_profile(self.queue.get_timestamp_period())
|
||||
})
|
||||
.flatten();
|
||||
output.present();
|
||||
Ok(profile)
|
||||
}
|
||||
}
|
||||
|
||||
fn view<'a>(
|
||||
resources: &'a crate::gpu_resource::GpuResources,
|
||||
surface: &'a wgpu::TextureView,
|
||||
id: &str,
|
||||
) -> Result<&'a wgpu::TextureView, String> {
|
||||
if id == "canvas" {
|
||||
Ok(surface)
|
||||
} else {
|
||||
resources
|
||||
.texture_view(id)
|
||||
.ok_or_else(|| "GRAPH_ATTACHMENT".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn color_ops(
|
||||
attachment: &ColorAttachment,
|
||||
final_attachment: &ColorAttachment,
|
||||
) -> Result<wgpu::Operations<wgpu::Color>, String> {
|
||||
let clear = attachment.clear.as_slice();
|
||||
Ok(wgpu::Operations {
|
||||
load: match attachment.load.as_str() {
|
||||
"load" => wgpu::LoadOp::Load,
|
||||
"clear" => wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: f64::from(clear.first().copied().unwrap_or(0.0)),
|
||||
g: f64::from(clear.get(1).copied().unwrap_or(0.0)),
|
||||
b: f64::from(clear.get(2).copied().unwrap_or(0.0)),
|
||||
a: f64::from(clear.get(3).copied().unwrap_or(1.0)),
|
||||
}),
|
||||
_ => return Err("GRAPH_LOAD_OP".into()),
|
||||
},
|
||||
store: store_op(&final_attachment.store)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn depth_ops(
|
||||
attachment: &DepthAttachment,
|
||||
final_attachment: &DepthAttachment,
|
||||
) -> Result<wgpu::Operations<f32>, String> {
|
||||
Ok(wgpu::Operations {
|
||||
load: match attachment.load.as_str() {
|
||||
"load" => wgpu::LoadOp::Load,
|
||||
"clear" => wgpu::LoadOp::Clear(attachment.clear),
|
||||
_ => return Err("GRAPH_LOAD_OP".into()),
|
||||
},
|
||||
store: store_op(&final_attachment.store)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn store_op(value: &str) -> Result<wgpu::StoreOp, String> {
|
||||
match value {
|
||||
"store" => Ok(wgpu::StoreOp::Store),
|
||||
"discard" => Ok(wgpu::StoreOp::Discard),
|
||||
_ => Err("GRAPH_STORE_OP".into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
pub struct Wgpu {
|
||||
_instance: wgpu::Instance,
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
pub format: wgpu::TextureFormat,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub timestamp_queries: bool,
|
||||
pub adapter: String,
|
||||
}
|
||||
|
||||
impl Wgpu {
|
||||
pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result<Self, String> {
|
||||
let width = canvas.width();
|
||||
let height = canvas.height();
|
||||
if width == 0 || height == 0 {
|
||||
return Err("CANVAS_SIZE".into());
|
||||
}
|
||||
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::BROWSER_WEBGPU,
|
||||
..Default::default()
|
||||
});
|
||||
let surface = instance
|
||||
.create_surface(wgpu::SurfaceTarget::OffscreenCanvas(canvas))
|
||||
.map_err(|_| "SURFACE")?;
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "WEBGPU_UNAVAILABLE")?;
|
||||
let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY;
|
||||
let info = adapter.get_info();
|
||||
let adapter_name = if info.name.is_empty() {
|
||||
format!("{:?} · {:?}", info.device_type, info.backend)
|
||||
} else {
|
||||
format!(
|
||||
"{} · {:?} · {:?}",
|
||||
info.name, info.device_type, info.backend
|
||||
)
|
||||
};
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "DEVICE")?;
|
||||
let config = surface
|
||||
.get_default_config(&adapter, width, height)
|
||||
.ok_or("SURFACE")?;
|
||||
let format = config.format;
|
||||
surface.configure(&device, &config);
|
||||
Ok(Self {
|
||||
_instance: instance,
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
timestamp_queries: required_features.contains(wgpu::Features::TIMESTAMP_QUERY),
|
||||
adapter: adapter_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
const types = { f32: Float32Array, u32: Uint32Array, i32: Int32Array };
|
||||
|
||||
export class SharedRows {
|
||||
constructor(buffer, descriptor) {
|
||||
this.buffer = buffer;
|
||||
this.descriptor = Object.freeze(descriptor);
|
||||
}
|
||||
|
||||
get name() { return this.descriptor.name; }
|
||||
get rows() { return this.descriptor.rows; }
|
||||
get stride() { return this.descriptor.stride; }
|
||||
get format() { return this.descriptor.format; }
|
||||
get view() {
|
||||
return new types[this.format](this.buffer, this.descriptor.offset, this.rows * this.stride / 4);
|
||||
}
|
||||
|
||||
row(index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE");
|
||||
const width = this.stride / 4;
|
||||
return this.view.subarray(index * width, (index + 1) * width);
|
||||
}
|
||||
|
||||
read(index) { return Array.from(this.row(index)); }
|
||||
write(index, values) {
|
||||
const row = this.row(index);
|
||||
if (!values || values.length !== row.length) throw new RangeError("ROW_WIDTH");
|
||||
row.set(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
share() { return { buffer: this.buffer, descriptor: this.descriptor }; }
|
||||
}
|
||||
|
||||
export class YawnCore {
|
||||
#worker;
|
||||
#buffer;
|
||||
#arrays = new Map();
|
||||
#pending = new Map();
|
||||
#next = 1;
|
||||
|
||||
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, workerFactory } = {}) {
|
||||
if (!canvas) throw new TypeError("canvas is required");
|
||||
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-core",
|
||||
});
|
||||
this.#worker.addEventListener("message", ({ data }) => this.#message(data));
|
||||
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
||||
this.#worker.start?.();
|
||||
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
||||
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen])
|
||||
.then(({ buffer }) => { this.#buffer = buffer; });
|
||||
}
|
||||
|
||||
async allocateRows({ name, rows, stride, format }) {
|
||||
await this.ready;
|
||||
const descriptor = await this.#request("allocate", { name, rows, stride, format });
|
||||
const array = new SharedRows(this.#buffer, descriptor);
|
||||
this.#arrays.set(name, array);
|
||||
return array;
|
||||
}
|
||||
|
||||
array(name) {
|
||||
const array = this.#arrays.get(name);
|
||||
if (!array) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
||||
return array;
|
||||
}
|
||||
|
||||
async loadGraph(serialized) {
|
||||
await this.ready;
|
||||
return this.#request("load-graph", { serialized });
|
||||
}
|
||||
|
||||
#request(type, payload, transfer = []) {
|
||||
const request = this.#next++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pending.set(request, { resolve, reject });
|
||||
this.#worker.postMessage({ type, request, ...payload }, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
#message(message) {
|
||||
const pending = this.#pending.get(message?.request);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(message.request);
|
||||
if (message.error) pending.reject(Object.assign(new Error(message.error), { code: message.error }));
|
||||
else pending.resolve(message.result);
|
||||
}
|
||||
|
||||
#fail(code) {
|
||||
for (const { reject } of this.#pending.values()) reject(Object.assign(new Error(code), { code }));
|
||||
this.#pending.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#fail("DISPOSED");
|
||||
this.#worker.terminate();
|
||||
}
|
||||
}
|
||||
-424
@@ -1,424 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde_json::{Map, Number, Value};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
const ALIGNMENT: u32 = 64;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Core {
|
||||
_arena: Box<[u8]>,
|
||||
base: u32,
|
||||
capacity: u32,
|
||||
used: u32,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Core {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(arena_bytes: u32) -> Result<Self, JsError> {
|
||||
if arena_bytes < ALIGNMENT || arena_bytes > u32::MAX - (ALIGNMENT - 1) {
|
||||
return Err(JsError::new("INIT"));
|
||||
}
|
||||
let mut arena = vec![0; (arena_bytes + ALIGNMENT - 1) as usize].into_boxed_slice();
|
||||
let pointer = arena.as_mut_ptr() as usize;
|
||||
let base = ((pointer + ALIGNMENT as usize - 1) & !(ALIGNMENT as usize - 1)) as u32;
|
||||
Ok(Self {
|
||||
_arena: arena,
|
||||
base,
|
||||
capacity: arena_bytes,
|
||||
used: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn allocate(&mut self, rows: u32, stride: u32, format: &str) -> Result<u32, JsError> {
|
||||
if rows == 0 || stride < 16 || stride % 16 != 0 || !matches!(format, "f32" | "u32" | "i32")
|
||||
{
|
||||
return Err(JsError::new("ALLOCATION"));
|
||||
}
|
||||
let offset = align(self.used, ALIGNMENT).ok_or_else(|| JsError::new("ARENA_OOM"))?;
|
||||
let bytes = rows
|
||||
.checked_mul(stride)
|
||||
.ok_or_else(|| JsError::new("ARENA_OOM"))?;
|
||||
self.used = offset
|
||||
.checked_add(bytes)
|
||||
.filter(|end| *end <= self.capacity)
|
||||
.ok_or_else(|| JsError::new("ARENA_OOM"))?;
|
||||
Ok(self.base + offset)
|
||||
}
|
||||
|
||||
pub fn compile_graph(&self, source: &str) -> Result<String, JsError> {
|
||||
compile_graph(source).map_err(JsError::new)
|
||||
}
|
||||
}
|
||||
|
||||
fn align(value: u32, alignment: u32) -> Option<u32> {
|
||||
value
|
||||
.checked_add(alignment - 1)
|
||||
.map(|value| value & !(alignment - 1))
|
||||
}
|
||||
|
||||
enum Expression {
|
||||
Atom(Value),
|
||||
List(Vec<Expression>),
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
source: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn parse(source: &'a str) -> Result<Expression, &'static str> {
|
||||
let mut parser = Parser {
|
||||
source: source.as_bytes(),
|
||||
at: 0,
|
||||
};
|
||||
let expression = parser.expression()?;
|
||||
parser.whitespace();
|
||||
if parser.at != parser.source.len() {
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
Ok(expression)
|
||||
}
|
||||
|
||||
fn expression(&mut self) -> Result<Expression, &'static str> {
|
||||
self.whitespace();
|
||||
match self.source.get(self.at) {
|
||||
Some(b'(') => self.list(),
|
||||
Some(b'"') => self.string(),
|
||||
Some(b')') | None => Err("GRAPH_WIRE"),
|
||||
Some(_) => self.atom(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(&mut self) -> Result<Expression, &'static str> {
|
||||
self.at += 1;
|
||||
let mut values = Vec::new();
|
||||
loop {
|
||||
self.whitespace();
|
||||
match self.source.get(self.at) {
|
||||
Some(b')') => {
|
||||
self.at += 1;
|
||||
return Ok(Expression::List(values));
|
||||
}
|
||||
None => return Err("GRAPH_WIRE"),
|
||||
_ => values.push(self.expression()?),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<Expression, &'static str> {
|
||||
let start = self.at;
|
||||
self.at += 1;
|
||||
let mut escaped = false;
|
||||
while let Some(&byte) = self.source.get(self.at) {
|
||||
self.at += 1;
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
let value = serde_json::from_slice(&self.source[start..self.at])
|
||||
.map_err(|_| "GRAPH_WIRE")?;
|
||||
return Ok(Expression::Atom(Value::String(value)));
|
||||
}
|
||||
}
|
||||
Err("GRAPH_WIRE")
|
||||
}
|
||||
|
||||
fn atom(&mut self) -> Result<Expression, &'static str> {
|
||||
let start = self.at;
|
||||
while self
|
||||
.source
|
||||
.get(self.at)
|
||||
.is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b'(' | b')'))
|
||||
{
|
||||
self.at += 1;
|
||||
}
|
||||
let token = std::str::from_utf8(&self.source[start..self.at]).map_err(|_| "GRAPH_WIRE")?;
|
||||
let value = match token {
|
||||
"true" => Value::Bool(true),
|
||||
"false" => Value::Bool(false),
|
||||
"null" => Value::Null,
|
||||
_ => serde_json::from_str::<Value>(token)
|
||||
.ok()
|
||||
.filter(Value::is_number)
|
||||
.unwrap_or_else(|| Value::String(token.into())),
|
||||
};
|
||||
Ok(Expression::Atom(value))
|
||||
}
|
||||
|
||||
fn whitespace(&mut self) {
|
||||
while self
|
||||
.source
|
||||
.get(self.at)
|
||||
.is_some_and(u8::is_ascii_whitespace)
|
||||
{
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_graph(source: &str) -> Result<String, &'static str> {
|
||||
let root = Parser::parse(source)?;
|
||||
let Expression::List(mut root) = root else {
|
||||
return Err("GRAPH_WIRE");
|
||||
};
|
||||
if root.len() != 3
|
||||
|| !matches!(&root[0], Expression::Atom(Value::String(tag)) if tag == "yawn-graph")
|
||||
|| !matches!(&root[1], Expression::Atom(Value::Number(version)) if version.as_u64() == Some(1))
|
||||
{
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let mut graph = decode(root.pop().unwrap())?;
|
||||
let object = graph.as_object_mut().ok_or("GRAPH_SHAPE")?;
|
||||
if !object.get("id").is_some_and(Value::is_string) {
|
||||
return Err("GRAPH_SHAPE");
|
||||
}
|
||||
let passes = sort_passes(object.get("passes").ok_or("GRAPH_PASS")?)?;
|
||||
plan_resources(object, &passes)?;
|
||||
object.insert("passes".into(), Value::Array(passes));
|
||||
serde_json::to_string(&graph).map_err(|_| "GRAPH_WIRE")
|
||||
}
|
||||
|
||||
fn decode(expression: Expression) -> Result<Value, &'static str> {
|
||||
let Expression::List(mut values) = expression else {
|
||||
return match expression {
|
||||
Expression::Atom(value) => Ok(value),
|
||||
Expression::List(_) => unreachable!(),
|
||||
};
|
||||
};
|
||||
if values.is_empty() {
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let tag = match values.remove(0) {
|
||||
Expression::Atom(Value::String(tag)) => tag,
|
||||
_ => return Err("GRAPH_WIRE"),
|
||||
};
|
||||
if tag == "array" {
|
||||
return values.into_iter().map(decode).collect();
|
||||
}
|
||||
if tag != "object" {
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let mut object = Map::new();
|
||||
for field in values {
|
||||
let Expression::List(mut field) = field else {
|
||||
return Err("GRAPH_WIRE");
|
||||
};
|
||||
if field.len() != 3
|
||||
|| !matches!(&field[0], Expression::Atom(Value::String(tag)) if tag == "field")
|
||||
{
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
let value = decode(field.pop().unwrap())?;
|
||||
let key = match field.pop().unwrap() {
|
||||
Expression::Atom(Value::String(key)) => key,
|
||||
_ => return Err("GRAPH_WIRE"),
|
||||
};
|
||||
if object.insert(key, value).is_some() {
|
||||
return Err("GRAPH_WIRE");
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
|
||||
fn sort_passes(value: &Value) -> Result<Vec<Value>, &'static str> {
|
||||
let passes = value.as_array().ok_or("GRAPH_PASS")?;
|
||||
let mut ids = HashMap::new();
|
||||
for (index, pass) in passes.iter().enumerate() {
|
||||
let id = pass
|
||||
.as_object()
|
||||
.and_then(|pass| pass.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("GRAPH_PASS")?;
|
||||
if ids.insert(id, index).is_some() {
|
||||
return Err("GRAPH_PASS");
|
||||
}
|
||||
}
|
||||
let mut dependencies = Vec::with_capacity(passes.len());
|
||||
for pass in passes {
|
||||
let after = pass.get("after").map_or(Ok(&[][..]), |after| {
|
||||
after.as_array().map(Vec::as_slice).ok_or("GRAPH_ARRAY")
|
||||
})?;
|
||||
dependencies.push(
|
||||
after
|
||||
.iter()
|
||||
.map(|dependency| {
|
||||
dependency
|
||||
.as_str()
|
||||
.and_then(|id| ids.get(id).copied())
|
||||
.ok_or("GRAPH_DEPENDENCY")
|
||||
})
|
||||
.collect::<Result<HashSet<_>, _>>()?,
|
||||
);
|
||||
}
|
||||
let mut emitted = vec![false; passes.len()];
|
||||
let mut result = Vec::with_capacity(passes.len());
|
||||
while result.len() < passes.len() {
|
||||
let ready = (0..passes.len()).find(|&index| {
|
||||
!emitted[index]
|
||||
&& dependencies[index]
|
||||
.iter()
|
||||
.all(|dependency| emitted[*dependency])
|
||||
});
|
||||
let index = ready.ok_or("GRAPH_CYCLE")?;
|
||||
emitted[index] = true;
|
||||
result.push(passes[index].clone());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn plan_resources(graph: &mut Map<String, Value>, passes: &[Value]) -> Result<(), &'static str> {
|
||||
let mut used = HashSet::new();
|
||||
let mut lifetimes = HashMap::new();
|
||||
for (frame, pass) in passes.iter().enumerate() {
|
||||
for id in pass_resources(pass)? {
|
||||
used.insert(id.to_owned());
|
||||
lifetimes
|
||||
.entry(id.to_owned())
|
||||
.and_modify(|lifetime: &mut (usize, usize)| lifetime.1 = frame)
|
||||
.or_insert((frame, frame));
|
||||
}
|
||||
}
|
||||
let Some(resources) = graph.get_mut("resources") else {
|
||||
return Ok(());
|
||||
};
|
||||
let resources = resources.as_object_mut().ok_or("GRAPH_RESOURCE")?;
|
||||
for kind in ["buffers", "samplers"] {
|
||||
if let Some(declarations) = resources.get_mut(kind) {
|
||||
let declarations = declarations.as_array_mut().ok_or("GRAPH_RESOURCE")?;
|
||||
let mut ids = HashSet::new();
|
||||
declarations.retain(|declaration| {
|
||||
declaration
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| ids.insert(id.to_owned()) && used.contains(id))
|
||||
});
|
||||
}
|
||||
}
|
||||
let Some(textures) = resources.get_mut("textures") else {
|
||||
return Ok(());
|
||||
};
|
||||
let textures = textures.as_array_mut().ok_or("GRAPH_RESOURCE")?;
|
||||
let mut ids = HashSet::new();
|
||||
let mut planned = Vec::new();
|
||||
for mut declaration in textures.drain(..) {
|
||||
let object = declaration.as_object_mut().ok_or("GRAPH_RESOURCE")?;
|
||||
let id = object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("GRAPH_RESOURCE")?;
|
||||
if !ids.insert(id.to_owned()) {
|
||||
return Err("GRAPH_RESOURCE");
|
||||
}
|
||||
let Some(&(first, last)) = lifetimes.get(id) else {
|
||||
continue;
|
||||
};
|
||||
planned.push((declaration, first, last));
|
||||
}
|
||||
planned.sort_by_key(|value| value.1);
|
||||
let mut output = Vec::new();
|
||||
let mut slots: Vec<(String, usize)> = Vec::new();
|
||||
for (mut declaration, first, last) in planned {
|
||||
let object = declaration.as_object_mut().unwrap();
|
||||
let key = texture_key(object)?;
|
||||
let slot = if object.get("transient") == Some(&Value::Bool(false)) {
|
||||
None
|
||||
} else {
|
||||
slots
|
||||
.iter()
|
||||
.position(|value| value.0 == key && value.1 < first)
|
||||
};
|
||||
let slot = match slot {
|
||||
Some(slot) => {
|
||||
slots[slot].1 = last;
|
||||
slot
|
||||
}
|
||||
None => {
|
||||
slots.push((key, last));
|
||||
slots.len() - 1
|
||||
}
|
||||
};
|
||||
object.insert("slot".into(), Value::Number(Number::from(slot as u64)));
|
||||
output.push(declaration);
|
||||
}
|
||||
*textures = output;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pass_resources(pass: &Value) -> Result<Vec<&str>, &'static str> {
|
||||
let mut result = Vec::new();
|
||||
for field in ["bindings", "color", "vertexBuffers"] {
|
||||
if let Some(values) = pass.get(field) {
|
||||
for value in values.as_array().ok_or("GRAPH_ARRAY")? {
|
||||
result.push(
|
||||
value
|
||||
.get("resource")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("GRAPH_RESOURCE")?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for field in ["depth", "indexBuffer"] {
|
||||
if let Some(value) = pass.get(field) {
|
||||
result.push(
|
||||
value
|
||||
.get("resource")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("GRAPH_RESOURCE")?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn texture_key(texture: &Map<String, Value>) -> Result<String, &'static str> {
|
||||
let mut size = texture.get("size").cloned().unwrap_or_else(|| {
|
||||
Value::Array(vec![
|
||||
Value::String("canvas".into()),
|
||||
Value::String("canvas".into()),
|
||||
])
|
||||
});
|
||||
let size = size.as_array_mut().ok_or("GRAPH_TEXTURE_SIZE")?;
|
||||
if !(2..=3).contains(&size.len()) {
|
||||
return Err("GRAPH_TEXTURE_SIZE");
|
||||
}
|
||||
if size.len() == 2 {
|
||||
size.push(Value::Number(Number::from(1)));
|
||||
}
|
||||
let format = texture
|
||||
.get("format")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("GRAPH_RESOURCE")?;
|
||||
let mut usage = texture.get("usage").map_or(Ok(Vec::new()), |usage| {
|
||||
usage
|
||||
.as_array()
|
||||
.ok_or("GRAPH_TEXTURE_USAGE")?
|
||||
.iter()
|
||||
.map(|value| value.as_str().ok_or("GRAPH_TEXTURE_USAGE"))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})?;
|
||||
usage.sort_unstable();
|
||||
usage.dedup();
|
||||
serde_json::to_string(&(
|
||||
size,
|
||||
format,
|
||||
usage,
|
||||
texture
|
||||
.get("mipLevelCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1),
|
||||
texture
|
||||
.get("sampleCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1),
|
||||
texture
|
||||
.get("dimension")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("2d"),
|
||||
))
|
||||
.map_err(|_| "GRAPH_RESOURCE")
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import initWasm, { Core as WasmCore } from "../pkg/yawn_core.js";
|
||||
|
||||
let canvas, context, device, surfaceFormat, memory, core, loadout;
|
||||
const arrays = new Map();
|
||||
const align = (value, multiple) => Math.ceil(value / multiple) * multiple;
|
||||
const fail = code => { throw new Error(code); };
|
||||
const list = value => value === undefined ? [] : Array.isArray(value) ? value : fail("GRAPH_ARRAY");
|
||||
|
||||
function index(items, code) {
|
||||
const result = new Map();
|
||||
for (const item of list(items)) {
|
||||
if (!item || typeof item.id !== "string" || result.has(item.id)) fail(code);
|
||||
result.set(item.id, item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const bufferUsage = names => list(names).reduce((usage, name) => usage | ({
|
||||
uniform: GPUBufferUsage.UNIFORM, storage: GPUBufferUsage.STORAGE,
|
||||
vertex: GPUBufferUsage.VERTEX, index: GPUBufferUsage.INDEX,
|
||||
indirect: GPUBufferUsage.INDIRECT, copySrc: GPUBufferUsage.COPY_SRC,
|
||||
}[name] ?? fail("GRAPH_BUFFER_USAGE")), GPUBufferUsage.COPY_DST);
|
||||
const textureUsage = names => list(names).reduce((usage, name) => usage | ({
|
||||
render: GPUTextureUsage.RENDER_ATTACHMENT, sampled: GPUTextureUsage.TEXTURE_BINDING,
|
||||
storage: GPUTextureUsage.STORAGE_BINDING, copySrc: GPUTextureUsage.COPY_SRC,
|
||||
copyDst: GPUTextureUsage.COPY_DST,
|
||||
}[name] ?? fail("GRAPH_TEXTURE_USAGE")), 0);
|
||||
|
||||
async function compile(graph) {
|
||||
if (!graph || typeof graph.id !== "string") fail("GRAPH_SHAPE");
|
||||
const passes = list(graph.passes);
|
||||
const renderDeclarations = index(graph.pipelines?.render, "GRAPH_PIPELINE");
|
||||
const computeDeclarations = index(graph.pipelines?.compute, "GRAPH_PIPELINE");
|
||||
const resources = new Map(), owned = [];
|
||||
try {
|
||||
for (const declaration of list(graph.resources?.buffers)) {
|
||||
const source = arrays.get(declaration.array);
|
||||
if (!source) fail("GRAPH_ARRAY_UNKNOWN");
|
||||
const gpu = device.createBuffer({ size: align(source.bytes, 4), usage: bufferUsage(declaration.usage) });
|
||||
resources.set(declaration.id, { kind: "buffer", gpu, source });
|
||||
owned.push(gpu);
|
||||
}
|
||||
|
||||
const textureSlots = new Map();
|
||||
for (const declaration of list(graph.resources?.textures)) {
|
||||
if (!Number.isInteger(declaration.slot)) fail("GRAPH_RESOURCE");
|
||||
const size = declaration.size ?? ["canvas", "canvas"];
|
||||
if (!Array.isArray(size) || size.length < 2 || size.length > 3) fail("GRAPH_TEXTURE_SIZE");
|
||||
const descriptor = {
|
||||
size: [size[0] === "canvas" ? canvas.width : size[0], size[1] === "canvas" ? canvas.height : size[1], size[2] ?? 1],
|
||||
format: declaration.format,
|
||||
usage: textureUsage(declaration.usage),
|
||||
mipLevelCount: declaration.mipLevelCount ?? 1,
|
||||
sampleCount: declaration.sampleCount ?? 1,
|
||||
dimension: declaration.dimension ?? "2d",
|
||||
};
|
||||
let slot = textureSlots.get(declaration.slot);
|
||||
if (!slot) {
|
||||
const gpu = device.createTexture(descriptor);
|
||||
slot = { gpu, view: gpu.createView() };
|
||||
textureSlots.set(declaration.slot, slot);
|
||||
owned.push(gpu);
|
||||
}
|
||||
resources.set(declaration.id, { kind: "texture", gpu: slot.gpu, view: slot.view });
|
||||
}
|
||||
for (const declaration of list(graph.resources?.samplers))
|
||||
resources.set(declaration.id, {
|
||||
kind: "sampler", gpu: device.createSampler(declaration.descriptor),
|
||||
});
|
||||
|
||||
const renderPipelines = new Map(), computePipelines = new Map();
|
||||
await Promise.all([...new Set(passes.filter(x => x.type === "render").map(x => x.pipeline))].map(async id => {
|
||||
const declaration = renderDeclarations.get(id);
|
||||
if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE");
|
||||
const module = device.createShaderModule({ code: declaration.code });
|
||||
renderPipelines.set(id, await device.createRenderPipelineAsync({
|
||||
layout: "auto",
|
||||
vertex: { module, entryPoint: declaration.vertex?.entry ?? "vertex", buffers: declaration.vertex?.buffers ?? [] },
|
||||
fragment: {
|
||||
module,
|
||||
entryPoint: declaration.fragment?.entry ?? "fragment",
|
||||
targets: list(declaration.fragment?.targets).map(target => ({
|
||||
...target, format: target.format === "canvas" ? surfaceFormat : target.format,
|
||||
})),
|
||||
},
|
||||
primitive: declaration.primitive,
|
||||
depthStencil: declaration.depthStencil,
|
||||
multisample: declaration.multisample,
|
||||
}));
|
||||
}));
|
||||
await Promise.all([...new Set(passes.filter(x => x.type === "compute").map(x => x.pipeline))].map(async id => {
|
||||
const declaration = computeDeclarations.get(id);
|
||||
if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE");
|
||||
const module = device.createShaderModule({ code: declaration.code });
|
||||
computePipelines.set(id, await device.createComputePipelineAsync({
|
||||
layout: "auto", compute: { module, entryPoint: declaration.entry ?? "main" },
|
||||
}));
|
||||
}));
|
||||
|
||||
const bindGroups = (pass, pipeline) => {
|
||||
const groups = new Map();
|
||||
for (const binding of list(pass.bindings)) {
|
||||
const resource = resources.get(binding.resource);
|
||||
if (!resource) fail("GRAPH_RESOURCE_UNKNOWN");
|
||||
const value = resource.kind === "buffer"
|
||||
? { buffer: resource.gpu, offset: binding.offset ?? 0, ...(binding.size ? { size: binding.size } : {}) }
|
||||
: resource.kind === "texture" ? resource.view : resource.gpu;
|
||||
if (!groups.has(binding.group ?? 0)) groups.set(binding.group ?? 0, []);
|
||||
groups.get(binding.group ?? 0).push({ binding: binding.binding, resource: value });
|
||||
}
|
||||
return [...groups].map(([group, entries]) => [group, device.createBindGroup({
|
||||
layout: pipeline.getBindGroupLayout(group), entries,
|
||||
})]);
|
||||
};
|
||||
const compiled = passes.map(pass => {
|
||||
const pipeline = (pass.type === "render" ? renderPipelines : computePipelines).get(pass.pipeline);
|
||||
if (!pipeline) fail("GRAPH_PASS");
|
||||
return { pass, pipeline, bindGroups: bindGroups(pass, pipeline) };
|
||||
});
|
||||
return { id: graph.id, passes: compiled, resources, owned };
|
||||
} catch (error) {
|
||||
owned.forEach(resource => resource.destroy?.());
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const clearColor = (value = [0, 0, 0, 1]) => Array.isArray(value)
|
||||
? { r: value[0], g: value[1], b: value[2], a: value[3] }
|
||||
: value;
|
||||
|
||||
function render() {
|
||||
if (!loadout) return;
|
||||
for (const resource of loadout.resources.values())
|
||||
if (resource.kind === "buffer") device.queue.writeBuffer(
|
||||
resource.gpu, 0, new Uint8Array(memory, resource.source.offset, resource.source.bytes),
|
||||
);
|
||||
const encoder = device.createCommandEncoder();
|
||||
for (const { pass, pipeline, bindGroups } of loadout.passes) {
|
||||
if (pass.type === "compute") {
|
||||
const command = encoder.beginComputePass();
|
||||
command.setPipeline(pipeline);
|
||||
bindGroups.forEach(([group, value]) => command.setBindGroup(group, value));
|
||||
command.dispatchWorkgroups(...(pass.dispatch ?? [1, 1, 1]));
|
||||
command.end();
|
||||
continue;
|
||||
}
|
||||
const view = id => id === "canvas"
|
||||
? context.getCurrentTexture().createView()
|
||||
: loadout.resources.get(id)?.view ?? fail("GRAPH_ATTACHMENT");
|
||||
const command = encoder.beginRenderPass({
|
||||
colorAttachments: list(pass.color).map(attachment => ({
|
||||
view: view(attachment.resource), loadOp: attachment.load ?? "clear",
|
||||
storeOp: attachment.store ?? "store", clearValue: clearColor(attachment.clear),
|
||||
})),
|
||||
...(pass.depth ? { depthStencilAttachment: {
|
||||
view: view(pass.depth.resource), depthLoadOp: pass.depth.load ?? "clear",
|
||||
depthStoreOp: pass.depth.store ?? "store", depthClearValue: pass.depth.clear ?? 1,
|
||||
} } : {}),
|
||||
});
|
||||
command.setPipeline(pipeline);
|
||||
bindGroups.forEach(([group, value]) => command.setBindGroup(group, value));
|
||||
list(pass.vertexBuffers).forEach(binding => command.setVertexBuffer(
|
||||
binding.slot ?? 0, loadout.resources.get(binding.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"), binding.offset ?? 0,
|
||||
));
|
||||
if (pass.indexBuffer) command.setIndexBuffer(
|
||||
loadout.resources.get(pass.indexBuffer.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"),
|
||||
pass.indexBuffer.format ?? "uint32", pass.indexBuffer.offset ?? 0,
|
||||
);
|
||||
const draw = pass.draw ?? {};
|
||||
if (pass.indexBuffer) command.drawIndexed(draw.indices ?? 0, draw.instances ?? 1, draw.firstIndex ?? 0, draw.baseVertex ?? 0, draw.firstInstance ?? 0);
|
||||
else command.draw(draw.vertices ?? 3, draw.instances ?? 1, draw.firstVertex ?? 0, draw.firstInstance ?? 0);
|
||||
command.end();
|
||||
}
|
||||
device.queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
try { render(); } catch (error) {
|
||||
postMessage({ type: "runtime-error", error: error?.message ?? "RENDER_ERROR" });
|
||||
loadout = undefined;
|
||||
}
|
||||
(globalThis.requestAnimationFrame ?? (callback => setTimeout(callback, 16)))(tick);
|
||||
}
|
||||
|
||||
addEventListener("message", async ({ data: message }) => {
|
||||
try {
|
||||
let result;
|
||||
if (message.type === "init") {
|
||||
if (!(message.canvas instanceof OffscreenCanvas) || !Number.isInteger(message.arenaBytes) || message.arenaBytes < 64) fail("INIT");
|
||||
canvas = message.canvas;
|
||||
const wasm = await initWasm();
|
||||
core = new WasmCore(message.arenaBytes);
|
||||
memory = wasm.memory.buffer;
|
||||
if (!(memory instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED");
|
||||
const adapter = await navigator.gpu?.requestAdapter();
|
||||
if (!adapter) fail("WEBGPU_UNAVAILABLE");
|
||||
device = await adapter.requestDevice();
|
||||
context = canvas.getContext("webgpu");
|
||||
surfaceFormat = navigator.gpu.getPreferredCanvasFormat();
|
||||
context.configure({ device, format: surfaceFormat, alphaMode: "opaque" });
|
||||
device.lost.then(() => { postMessage({ type: "runtime-error", error: "DEVICE_LOST" }); loadout = undefined; });
|
||||
result = { buffer: memory };
|
||||
tick();
|
||||
} else if (message.type === "allocate") {
|
||||
const { name, rows, stride, format } = message;
|
||||
if (typeof name !== "string" || !name || arrays.has(name)) fail("ALLOCATION");
|
||||
const offset = core.allocate(rows, stride, format), bytes = rows * stride;
|
||||
result = { name, rows, stride, format, offset };
|
||||
arrays.set(name, { ...result, bytes });
|
||||
} else if (message.type === "load-graph") {
|
||||
const next = await compile(JSON.parse(core.compile_graph(message.serialized)));
|
||||
const previous = loadout;
|
||||
loadout = next;
|
||||
previous?.owned.forEach(resource => resource.destroy?.());
|
||||
result = { id: next.id };
|
||||
} else fail("MESSAGE");
|
||||
postMessage({ request: message.request, result });
|
||||
} catch (error) {
|
||||
postMessage({ request: message.request, error: error?.message ?? "CORE_ERROR" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import initWasm, { Core } from "./pkg/yawn_core.js";
|
||||
|
||||
let core;
|
||||
let profileTimer;
|
||||
|
||||
const fail = code => { throw new Error(code); };
|
||||
|
||||
addEventListener("message", async ({ data: message }) => {
|
||||
try {
|
||||
let result;
|
||||
if (message?.type === "init") {
|
||||
if (core || !(message.canvas instanceof OffscreenCanvas)) fail("INIT");
|
||||
const wasm = await initWasm();
|
||||
core = new Core(message.arenaBytes);
|
||||
await core.initialize(message.canvas);
|
||||
const buffer = wasm.memory.buffer;
|
||||
if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED");
|
||||
result = { buffer, rows: JSON.parse(core.rows()) };
|
||||
} else {
|
||||
if (!core) fail("UNINITIALIZED");
|
||||
switch (message?.type) {
|
||||
case "create-rows":
|
||||
result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format));
|
||||
break;
|
||||
case "create-rows-batch":
|
||||
result = JSON.parse(core.create_rows_batch(JSON.stringify(message.rows)));
|
||||
break;
|
||||
case "delete-rows":
|
||||
core.delete_rows(message.name);
|
||||
break;
|
||||
case "allocate-object":
|
||||
result = JSON.parse(core.allocate_object(message.name));
|
||||
break;
|
||||
case "delete-object":
|
||||
core.delete_object(message.name, message.id);
|
||||
break;
|
||||
case "compile-graph":
|
||||
result = core.compile_graph(message.serialized);
|
||||
break;
|
||||
case "switch-loadout":
|
||||
core.switch_loadout(message.id);
|
||||
break;
|
||||
case "upload-texture":
|
||||
core.upload_texture(message.name, message.mipLevel, message.image);
|
||||
break;
|
||||
case "delete-texture":
|
||||
core.delete_texture(message.name);
|
||||
break;
|
||||
case "play":
|
||||
core.play();
|
||||
break;
|
||||
case "pause":
|
||||
core.pause();
|
||||
break;
|
||||
case "set-fps":
|
||||
core.set_fps(message.fps);
|
||||
break;
|
||||
case "set-profiler":
|
||||
result = core.set_profiler(Boolean(message.enabled));
|
||||
clearInterval(profileTimer);
|
||||
profileTimer = result && message.enabled ? setInterval(() => {
|
||||
const stats = core.take_profile();
|
||||
if (stats) postMessage({ type: "profile", stats: JSON.parse(stats) });
|
||||
}, 250) : undefined;
|
||||
break;
|
||||
default:
|
||||
fail("MESSAGE");
|
||||
}
|
||||
}
|
||||
postMessage({ request: message.request, result });
|
||||
} catch (error) {
|
||||
postMessage({ request: message?.request, error: error?.message ?? "CORE_ERROR" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const isolationHeaders = {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Yawn Core Wire API",
|
||||
description: "Raw worker, shared-memory, and render-graph reference",
|
||||
cleanUrls: true,
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "Boot", link: "/guide/boot" },
|
||||
{ text: "Shared memory", link: "/guide/shared-memory" },
|
||||
{ text: "Render graphs", link: "/guide/render-graphs" },
|
||||
{ text: "Wire reference", link: "/reference/worker" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "Guides",
|
||||
items: [
|
||||
{ text: "Raw-core overview", link: "/" },
|
||||
{ text: "Boot and transport", link: "/guide/boot" },
|
||||
{ text: "Shared memory", link: "/guide/shared-memory" },
|
||||
{ text: "Render graphs", link: "/guide/render-graphs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
{ text: "Worker messages", link: "/reference/worker" },
|
||||
{ text: "Graph schema", link: "/reference/graph-schema" },
|
||||
{ text: "Errors and lifecycle", link: "/reference/errors" },
|
||||
],
|
||||
},
|
||||
],
|
||||
search: { provider: "local" },
|
||||
},
|
||||
vite: {
|
||||
server: { headers: isolationHeaders },
|
||||
preview: { headers: isolationHeaders },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
# Boot and transport
|
||||
|
||||
## Isolation and deployment
|
||||
|
||||
`SharedArrayBuffer` requires a secure, cross-origin-isolated page. Serve the document and worker/package assets over HTTPS (localhost is also a secure context) with these response headers:
|
||||
|
||||
```http
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
```
|
||||
|
||||
Every cross-origin subresource must satisfy COEP (normally CORS or `Cross-Origin-Resource-Policy`). Check `window.crossOriginIsolated === true`, `HTMLCanvasElement.prototype.transferControlToOffscreen`, and `navigator.gpu` before boot. This site's VitePress config applies both headers in dev and preview; production hosting must do the same.
|
||||
|
||||
The worker imports `./pkg/yawn_core.js` relative to `core/worker.js`, so deploy the wasm-pack output beside it. The WASM build must use shared memory; initialization otherwise replies `WASM_MEMORY_NOT_SHARED`.
|
||||
|
||||
## A robust raw client
|
||||
|
||||
```js
|
||||
const canvas = document.querySelector("canvas");
|
||||
if (!crossOriginIsolated) throw new Error("cross-origin isolation required");
|
||||
if (!navigator.gpu) throw new Error("WebGPU required");
|
||||
|
||||
const worker = new Worker(new URL("../../core/worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-core-raw",
|
||||
});
|
||||
let nextRequest = 1;
|
||||
const pending = new Map();
|
||||
const profileListeners = new Set();
|
||||
|
||||
worker.addEventListener("message", ({ data: message }) => {
|
||||
if (message?.type === "profile") {
|
||||
for (const listener of profileListeners) listener(message.stats);
|
||||
return;
|
||||
}
|
||||
const operation = pending.get(message?.request);
|
||||
if (!operation) return; // late, duplicate, or foreign reply
|
||||
pending.delete(message.request);
|
||||
if (Object.prototype.hasOwnProperty.call(message, "error")) {
|
||||
const error = Object.assign(new Error(message.error), { code: message.error });
|
||||
operation.reject(error);
|
||||
} else {
|
||||
operation.resolve(message.result);
|
||||
}
|
||||
});
|
||||
|
||||
function failAll(code) {
|
||||
for (const { reject } of pending.values())
|
||||
reject(Object.assign(new Error(code), { code }));
|
||||
pending.clear();
|
||||
}
|
||||
worker.addEventListener("error", () => failAll("WORKER_ERROR"));
|
||||
worker.addEventListener("messageerror", () => failAll("WORKER_ERROR"));
|
||||
|
||||
function request(type, fields = {}, transfer = []) {
|
||||
const request = nextRequest++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(request, { resolve, reject });
|
||||
try {
|
||||
worker.postMessage({ type, request, ...fields }, transfer);
|
||||
} catch (error) {
|
||||
pending.delete(request);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
const { buffer, rows } = await request(
|
||||
"init",
|
||||
{ canvas: offscreen, arenaBytes: 64 * 1024 * 1024 },
|
||||
[offscreen],
|
||||
);
|
||||
```
|
||||
|
||||
Requests are `{ type: string, request: any, ...fields }`. Success is `{ request, result }`; void operations have `result: undefined`. Failure is `{ request, error: string }`. The worker merely echoes `request`, so use unique values. Requests can complete out of order. An unknown message type returns `MESSAGE`; all non-init operations before init return `UNINITIALIZED`.
|
||||
|
||||
`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer and all initial row descriptors (currently `signals`). Do not transfer the `SharedArrayBuffer`.
|
||||
|
||||
## Shutdown
|
||||
|
||||
Reject local pending promises, clear listeners/timers, and call `worker.terminate()`. There is no dispose request. Transferred canvases and `ImageBitmap`s cannot be reused by the sender. A fresh worker and canvas are required to restart after termination or failed initialization.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Render graphs from raw JavaScript
|
||||
|
||||
## Wire encoder
|
||||
|
||||
`compile-graph` does not accept JSON. It accepts one S-expression: `(yawn-graph 1 VALUE)`. Objects are `(object (field KEY VALUE)...)`; arrays are `(array VALUE...)`. Strings use JSON quoting. Bare `true`, `false`, `null`, and JSON numbers become their corresponding values; other bare atoms become strings. Quote all object keys and application strings to avoid ambiguity.
|
||||
|
||||
```js
|
||||
function graphValue(value) {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return `(array ${value.map(graphValue).join(" ")})`;
|
||||
if (typeof value === "object") {
|
||||
return `(object ${Object.entries(value).map(([key, item]) =>
|
||||
`(field ${JSON.stringify(key)} ${graphValue(item)})`
|
||||
).join(" ")})`;
|
||||
}
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
throw new TypeError("graph contains an unsupported value");
|
||||
}
|
||||
function encodeGraph(graph) {
|
||||
return `(yawn-graph 1 ${graphValue(graph)})`;
|
||||
}
|
||||
```
|
||||
|
||||
Duplicate object fields, malformed/trailing expressions, the wrong tag/version, and empty lists fail with `GRAPH_WIRE`.
|
||||
|
||||
## Minimal complete triangle
|
||||
|
||||
This graph needs no row arrays. `canvas` is a special attachment and fragment target format; do not declare it as a texture.
|
||||
|
||||
```js
|
||||
const triangle = {
|
||||
id: "raw-triangle",
|
||||
pipelines: {
|
||||
render: [{
|
||||
id: "triangle-pipeline",
|
||||
code: `
|
||||
struct Out { @builtin(position) position: vec4f, @location(0) color: vec3f }
|
||||
@vertex fn vertex(@builtin(vertex_index) i: u32) -> Out {
|
||||
var positions = array<vec2f, 3>(vec2f(0.0, 0.7), vec2f(-0.7, -0.7), vec2f(0.7, -0.7));
|
||||
var colors = array<vec3f, 3>(vec3f(1,0,0), vec3f(0,1,0), vec3f(0,0,1));
|
||||
var out: Out; out.position = vec4f(positions[i], 0, 1); out.color = colors[i]; return out;
|
||||
}
|
||||
@fragment fn fragment(in: Out) -> @location(0) vec4f { return vec4f(in.color, 1); }
|
||||
`,
|
||||
vertex: { entry: "vertex", buffers: [] },
|
||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||
}],
|
||||
},
|
||||
passes: [{
|
||||
id: "triangle",
|
||||
type: "render",
|
||||
pipeline: "triangle-pipeline",
|
||||
color: [{ resource: "canvas", load: "clear", store: "store", clear: [0.02, 0.02, 0.03, 1] }],
|
||||
draw: { vertices: 3, instances: 1 },
|
||||
}],
|
||||
};
|
||||
|
||||
const id = await request("compile-graph", { serialized: encodeGraph(triangle) });
|
||||
await request("switch-loadout", { id }); // activates and requests the first frame
|
||||
```
|
||||
|
||||
## Ordering and resources
|
||||
|
||||
`passes` declaration order is only a tie-breaker. Each pass's `after` IDs form a dependency DAG; compilation performs a stable-like first-ready topological ordering. Missing dependency → `GRAPH_DEPENDENCY`; cycle → `GRAPH_CYCLE`. Unused resources and pipelines are removed. Used textures receive lifetime slots; compatible transient textures with non-overlapping lifetimes can alias. Therefore do not expect an unused declaration to exist at runtime.
|
||||
|
||||
Adjacent compatible render passes may execute in one render pass/bundle. To merge they need matching attachments/sample count, previous `store`, next `load`, matching depth behavior, and the next pass must not bind its attachment as an input. Compute passes remain separate.
|
||||
|
||||
Buffers map an entire named SAB row array into a GPU buffer. Bindings are grouped by `group` and use the pipeline's automatically inferred WGSL layout. A binding resource ID may identify a buffer, texture view, or sampler. Vertex/index bindings must identify buffers. Color/depth attachments identify declared textures or special `canvas`. The WGSL declarations, usage flags, offsets, sizes, target formats, and attachment formats must agree; WebGPU validation failures can otherwise surface as generic worker/core errors.
|
||||
|
||||
See [Graph schema](/reference/graph-schema) for every field and default.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Shared rows and invalidation
|
||||
|
||||
## Descriptor and views
|
||||
|
||||
Every descriptor is:
|
||||
|
||||
```js
|
||||
{ name, rows, stride, format, offset, bytes }
|
||||
```
|
||||
|
||||
`offset` and `bytes` are byte units into the returned WASM `SharedArrayBuffer`; `bytes === rows * stride`. `format` is exactly `"f32"`, `"u32"`, or `"i32"`. `stride` is bytes per row, at least 16 and divisible by 16. Allocations begin on 64-byte boundaries. A row is homogeneous—the selected scalar type covers its entire stride.
|
||||
|
||||
```js
|
||||
const constructors = { f32: Float32Array, u32: Uint32Array, i32: Int32Array };
|
||||
function viewFor(buffer, d) {
|
||||
const Ctor = constructors[d.format];
|
||||
if (!Ctor) throw new Error("unsupported row format");
|
||||
return new Ctor(buffer, d.offset, d.bytes / Ctor.BYTES_PER_ELEMENT);
|
||||
}
|
||||
function rowFor(buffer, d, index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= d.rows) throw new RangeError();
|
||||
const view = viewFor(buffer, d);
|
||||
const width = d.stride / view.BYTES_PER_ELEMENT;
|
||||
return view.subarray(index * width, (index + 1) * width);
|
||||
}
|
||||
```
|
||||
|
||||
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor }`, even without growth. Never infer an address from a prior descriptor.
|
||||
|
||||
## Slots
|
||||
|
||||
Creating rows establishes capacity but does not allocate objects. `allocate-object` returns a zero-based row ID. IDs increase until capacity is exhausted; allocation then grows to `id + 1`. Deleted IDs are kept in a LIFO free list and reused. `delete-object` zeroes the entire row and releases the ID. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted.
|
||||
|
||||
Capacity does not shrink. Deletion makes descriptors/views invalid for application use even though stale bytes may remain in memory.
|
||||
|
||||
## Signals
|
||||
|
||||
`signals` is one `f32` row with 32-byte stride:
|
||||
|
||||
| Lane | Name | Meaning |
|
||||
|---:|---|---|
|
||||
| 0 | `deltaTime` | Seconds since the preceding accepted loop tick |
|
||||
| 1 | `frameCount` | Frame counter represented as f32 |
|
||||
| 2 | `elapsedTime` | Accumulated seconds |
|
||||
| 3 | `targetFps` | Configured cap; 0 means uncapped |
|
||||
| 4 | `skipRender` | Nonzero suppresses rendering |
|
||||
| 5 | `sabDirty` | Nonzero requests a frame; consumed (set to 0) at frame start |
|
||||
| 6 | `bundleDirty` | Nonzero suppresses stale bundles until a successful loadout switch |
|
||||
| 7 | `reserved` | No current contract; leave unchanged/zero |
|
||||
|
||||
The core updates lanes 0–3 while playing. A render starts only when lanes 4 and 6 are zero and lane 5 is nonzero. Finish the row writes first, then set the dirty signal:
|
||||
|
||||
```js
|
||||
const signalsF32 = rowFor(buffer, signalDescriptor, 0);
|
||||
|
||||
function publishRows(write) {
|
||||
write(); // finish every row write
|
||||
signalsF32[5] = 1; // request the frame last
|
||||
}
|
||||
|
||||
async function replaceBundle(mutate, compileAndSwitch) {
|
||||
signalsF32[6] = 1; // suppress the old bundle first
|
||||
mutate();
|
||||
signalsF32[5] = 1;
|
||||
try {
|
||||
await compileAndSwitch(); // successful switch clears lane 6 and requests a frame
|
||||
} catch (error) {
|
||||
// Keep lane 6 set: rendering stale bindings would be unsafe. Repair/retry or stop.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes.
|
||||
|
||||
## Frame-sync versus loadout-sync
|
||||
|
||||
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`.
|
||||
|
||||
`sync: "loadout"` is copied only when GPU resources are activated: on `switch-loadout`, and when core refreshes an active graph because a used row array grows/is recreated. Direct edits afterward do **not** reach that GPU buffer. To change loadout-synced content reliably, set `bundleDirty`, mutate, and switch a compiled replacement loadout (the replacement may use a newly compiled graph with the same schema). Structural changes affecting buffer size, bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations also require bundle invalidation and a graph/loadout switch. This distinction matters because render commands are precompiled into render bundles.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
layout: home
|
||||
hero:
|
||||
name: Yawn raw core
|
||||
text: Worker, shared memory, and render graphs
|
||||
tagline: The protocol reference for building an alternative handles layer or editor without YawnCore or @yawn/handles.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Boot from scratch
|
||||
link: /guide/boot
|
||||
- theme: alt
|
||||
text: Worker reference
|
||||
link: /reference/worker
|
||||
features:
|
||||
- title: Raw transport
|
||||
details: Start core/worker.js, correlate replies, and handle profiler events.
|
||||
- title: Shared rows
|
||||
details: Build typed views, allocate slots, and follow the dirty-lane protocol.
|
||||
- title: Graph wire format
|
||||
details: Encode, compile, and activate complete WebGPU render and compute graphs.
|
||||
---
|
||||
|
||||
## Contract at a glance
|
||||
|
||||
The browser main thread owns the HTML canvas and starts the module worker. The worker initializes the WASM core and WebGPU against an `OffscreenCanvas`. All control operations are structured-clone messages. Bulk numeric state lives in one returned `SharedArrayBuffer`; graphs describe how named row arrays become GPU resources.
|
||||
|
||||
The application is responsible for three important contracts:
|
||||
|
||||
1. Correlate each reply by its `request` value; profiler messages are unsolicited.
|
||||
2. Finish shared-memory writes before setting `signals[5]` (`sabDirty`).
|
||||
3. Before a mutation that invalidates compiled render bundles, set `signals[6]` (`bundleDirty`), make the mutation, compile and switch a replacement graph, then let successful `switch-loadout` clear lane 6.
|
||||
|
||||
This site describes the current wire API, not the higher-level wrapper API.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Errors and lifecycle
|
||||
|
||||
Worker failures are string codes in `{ request, error }`. GPU/browser validation can also produce implementation-originated error messages rather than a stable code; preserve the complete string in logs while branching only on documented codes.
|
||||
|
||||
## Transport and initialization
|
||||
|
||||
| Code | Meaning / response |
|
||||
|---|---|
|
||||
| `INIT` | Duplicate init, wrong canvas type, or invalid arena capacity. Start a fresh worker/canvas or fix capacity. |
|
||||
| `WASM_MEMORY_NOT_SHARED` | WASM was built/deployed without shared memory or isolation failed. |
|
||||
| `UNINITIALIZED` | Non-init request arrived before successful init. Serialize boot. |
|
||||
| `MESSAGE` | Unknown/missing message type. |
|
||||
| `CORE_ERROR` | Fallback when the thrown value has no message. Treat as fatal/unknown. |
|
||||
| `WEBGPU_UNINITIALIZED` | A GPU operation occurred without initialized WebGPU. |
|
||||
|
||||
`WORKER_ERROR` and `DISPOSED` are wrapper conventions, not messages emitted by `worker.js`; a raw client should use equivalent local codes when rejecting pending work.
|
||||
|
||||
## Rows, arena, and objects
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `ROWS` | Invalid row shape/format, incompatible recreation, empty batch, or malformed batch payload. |
|
||||
| `ROWS_BUILTIN` | Attempt to grow/delete/allocate from `signals`. |
|
||||
| `ROWS_UNKNOWN` | Row name does not exist (also possible during frame upload). |
|
||||
| `ROWS_ACTIVE` | Delete blocked by active object IDs or active graph reference. |
|
||||
| `ARENA_OOM` | Byte multiplication overflow or no aligned free arena block. |
|
||||
| `OBJECT_LIMIT` | Slot ID cannot increment past u32. |
|
||||
| `OBJECT_UNKNOWN` | Delete ID is not active for that row array. |
|
||||
| `FPS` | FPS exceeds 1000. |
|
||||
|
||||
On row growth, consume the returned descriptor before the next access. Batch creation is not rollback-safe. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
|
||||
|
||||
## Graph parse and planning
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `GRAPH_WIRE` | Invalid S-expression/tag/version, duplicate field, or unsupported wire value. |
|
||||
| `GRAPH_SHAPE` | Decoded object cannot deserialize, graph ID empty, or no passes. |
|
||||
| `GRAPH_PASS` | Empty/duplicate pass ID or invalid pass type. |
|
||||
| `GRAPH_DEPENDENCY` / `GRAPH_CYCLE` | Missing `after` ID / dependency cycle. |
|
||||
| `GRAPH_RESOURCE` | Empty/duplicate resource ID or texture planning serialization failure. |
|
||||
| `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. |
|
||||
| `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `loadout`. |
|
||||
| `GRAPH_UNKNOWN` | Switch ID has not been compiled/stored. |
|
||||
| `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. |
|
||||
| `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. |
|
||||
| `GRAPH_ATTACHMENT` | Attachment is neither `canvas` nor a declared active texture. |
|
||||
|
||||
## GPU descriptor/value errors
|
||||
|
||||
| Codes | Meaning |
|
||||
|---|---|
|
||||
| `GRAPH_BUFFER_USAGE`, `GRAPH_TEXTURE_USAGE` | Unknown usage string. |
|
||||
| `GRAPH_TEXTURE_SIZE`, `GRAPH_TEXTURE_DIMENSION`, `GRAPH_TEXTURE_FORMAT` | Invalid extent, dimension, or texture format. |
|
||||
| `GRAPH_TEXTURE_UNKNOWN` | Upload target absent from active resources (normally only reached for an active upload path). |
|
||||
| `GRAPH_VERTEX_FORMAT`, `GRAPH_VERTEX_STEP`, `GRAPH_INDEX_FORMAT` | Invalid vertex layout/index enum. |
|
||||
| `GRAPH_PRIMITIVE`, `GRAPH_DEPTH_STENCIL`, `GRAPH_MULTISAMPLE` | Invalid pipeline descriptor. |
|
||||
| `GRAPH_BLEND`, `GRAPH_WRITE_MASK` | Invalid fragment blend or write-mask bits. |
|
||||
| `GRAPH_SAMPLER` | Invalid sampler enum/compare value. |
|
||||
| `GRAPH_LOAD_OP`, `GRAPH_STORE_OP` | Attachment operation is not allowed. |
|
||||
| `SURFACE` | Surface acquisition/recovery failed; core marks the SAB dirty to retry later. |
|
||||
|
||||
Shader compilation, bind-layout mismatch, device limits, invalid upload extents/mips, and WebGPU validation are not all normalized to these codes. Validate graph inputs before switching and retain the previous graph source for recovery.
|
||||
|
||||
## Safe state transitions
|
||||
|
||||
1. Initialize once and cache descriptors.
|
||||
2. Create rows, allocate IDs, and initialize complete rows before publishing `sabDirty`.
|
||||
3. Compile graphs before switching. Compilation stores a graph but does not validate every GPU-dependent property; activation can still fail.
|
||||
4. For bundle-invalidating mutation, set `bundleDirty` first. Leave it set after compile/switch failure, because that intentionally suppresses stale rendering. Repair and switch successfully; never clear it merely to hide an error.
|
||||
5. Upload textures with transfer ownership. Delete cached sources when no future loadout needs them.
|
||||
6. Pause when editor state should stop timing/render-loop progress. `pause` does not cancel requests or clear dirty lanes.
|
||||
7. On worker failure, reject all pending requests and rebuild the entire worker/canvas/core state. Requests are not generally safe to replay because create, allocate, delete, switch, and texture operations may already have committed.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Render-graph schema
|
||||
|
||||
Field names are case-sensitive camelCase where shown. Unknown JSON fields are ignored by Serde; do not rely on that for versioning. Fields marked required have no default.
|
||||
|
||||
## Root and declarations
|
||||
|
||||
```text
|
||||
Graph {
|
||||
id: string required, nonempty
|
||||
resources: Resources = {}
|
||||
pipelines: Pipelines = {}
|
||||
passes: Pass[] required, nonempty
|
||||
}
|
||||
Resources { buffers: Buffer[] = [], textures: Texture[] = [], samplers: Sampler[] = [] }
|
||||
Pipelines { render: RenderPipeline[] = [], compute: ComputePipeline[] = [] }
|
||||
```
|
||||
|
||||
Resource IDs must be nonempty and unique across buffers, textures, and samplers. Pipeline IDs must be nonempty and unique across render and compute pipelines. Pass IDs must be nonempty and unique. Only declarations reached from passes survive compilation.
|
||||
|
||||
### Buffer
|
||||
|
||||
```text
|
||||
{ id: string required,
|
||||
array: string required, // existing shared-row name at switch time
|
||||
usage: string[] = [],
|
||||
sync: "frame" | "loadout" = "frame" }
|
||||
```
|
||||
|
||||
Allowed usage names: `uniform`, `storage`, `vertex`, `index`, `indirect`, `copySrc`. `COPY_DST` is always added. Include every way the graph uses the buffer. The allocation is at least 4 bytes, but data copied is the row descriptor's full `bytes`.
|
||||
|
||||
### Texture
|
||||
|
||||
```text
|
||||
{ id: string required,
|
||||
size: (number | "canvas")[] = [], // width, height, depth/layers
|
||||
format: string required,
|
||||
usage: string[] = [],
|
||||
mipLevelCount: u32 = 1,
|
||||
sampleCount: u32 = 1,
|
||||
dimension: "1d" | "2d" | "3d" = "2d",
|
||||
transient: boolean = true }
|
||||
```
|
||||
|
||||
Missing size components default to canvas width, canvas height, then 1. A string extent is valid only when exactly `"canvas"`. Usage names are `render`, `sampled`, `storage`, `copySrc`, `copyDst`. `format` uses wgpu/WebGPU texture-format spellings such as `rgba8unorm`, `rgba8unorm-srgb`, `bgra8unorm`, `r32float`, `rgba16float`, `depth16unorm`, `depth24plus`, `depth24plus-stencil8`, or `depth32float`; support is device-dependent. `canvas` is allowed only as a fragment target/attachment pseudo-format, not as a declared texture format. Non-transient compatible textures may persist across loadouts; transient textures may alias when lifetimes do not overlap.
|
||||
|
||||
### Sampler
|
||||
|
||||
```text
|
||||
{ id: string required, descriptor: object | any = {} }
|
||||
```
|
||||
|
||||
If descriptor is not an object, all defaults apply. Object fields: `addressModeU/V/W` (`clamp-to-edge` default; also `repeat`, `mirror-repeat`), `magFilter`, `minFilter`, `mipmapFilter` (`nearest` default or `linear`), `lodMinClamp` (0), `lodMaxClamp` (32), `compare` (absent or WebGPU compare function: `never`, `less`, `equal`, `less-equal`, `greater`, `not-equal`, `greater-equal`, `always`), and `anisotropyClamp` (1).
|
||||
|
||||
## Pipelines
|
||||
|
||||
### Render pipeline
|
||||
|
||||
```text
|
||||
{ id: string required, code: WGSL string required,
|
||||
vertex: VertexStage = {}, fragment: FragmentStage = {},
|
||||
primitive: object | null = null,
|
||||
depthStencil: object | null = null,
|
||||
multisample: object | null = null }
|
||||
|
||||
VertexStage { entry: string = "vertex", buffers: VertexLayout[] = [] }
|
||||
VertexLayout { arrayStride: u64 required, stepMode: "vertex"|"instance" = "vertex",
|
||||
attributes: VertexAttribute[] = [] }
|
||||
VertexAttribute { format: string required, offset: u64 required, shaderLocation: u32 required }
|
||||
FragmentStage { entry: string = "fragment", targets: FragmentTarget[] = [] }
|
||||
FragmentTarget { format: string required, blend: object|null = null, writeMask?: u32 }
|
||||
```
|
||||
|
||||
Vertex formats use wgpu/WebGPU names: `uint8x2/4`, `sint8x2/4`, `unorm8x2/4`, `snorm8x2/4`, `uint16x2/4`, `sint16x2/4`, `unorm16x2/4`, `snorm16x2/4`, `float16x2/4`, `float32`, `float32x2/3/4`, `uint32`, `uint32x2/3/4`, `sint32`, `sint32x2/3/4`, plus formats supported by the current wgpu build.
|
||||
|
||||
`primitive` and `blend` deserialize using wgpu's WebGPU-shaped camelCase descriptors. Important allowed primitive values are topology `point-list`, `line-list`, `line-strip`, `triangle-list` (default), `triangle-strip`; strip index format `uint16`/`uint32`; front face `ccw`/`cw`; cull mode absent/`front`/`back`; polygon mode normally `fill`. The `depthStencil` value follows wgpu's Rust Serde field names: it requires `format`, `depth_write_enabled`, and `depth_compare`; optional `stencil` uses `front`, `back`, `read_mask`, and `write_mask`, while optional `bias` uses `constant`, `slope_scale`, and `clamp`. Blend components use factors such as `zero`, `one`, `src`, `one-minus-src`, `src-alpha`, `one-minus-src-alpha`, `dst`, `one-minus-dst` and operations `add`, `subtract`, `reverse-subtract`, `min`, `max`. `writeMask` is ColorWrites bits (`RED=1`, `GREEN=2`, `BLUE=4`, `ALPHA=8`, all=15); omitted means all.
|
||||
|
||||
Multisample defaults are `{ count: 1, mask: 2^64-1, alphaToCoverageEnabled: false }`. Fragment targets omitted/empty means no fragment stage. Pipeline target order and formats must match pass color attachments.
|
||||
|
||||
### Compute pipeline
|
||||
|
||||
```text
|
||||
{ id: string required, code: WGSL string required, entry: string = "main" }
|
||||
```
|
||||
|
||||
## Passes
|
||||
|
||||
```text
|
||||
Pass {
|
||||
id: string required,
|
||||
type: "render" | "compute" required,
|
||||
pipeline: string required,
|
||||
after: string[] = [],
|
||||
bindings: Binding[] = [],
|
||||
color: ColorAttachment[] = [],
|
||||
depth?: DepthAttachment,
|
||||
vertexBuffers: VertexBinding[] = [],
|
||||
indexBuffer?: IndexBinding,
|
||||
draw: Draw = {},
|
||||
dispatch: [u32,u32,u32] = [1,1,1]
|
||||
}
|
||||
Binding { group: u32 = 0, binding: u32 required, resource: string required,
|
||||
offset: u64 = 0, size?: u64 }
|
||||
ColorAttachment { resource: string required, clear: number[] = [],
|
||||
load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
|
||||
DepthAttachment { resource: string required, clear: number = 1,
|
||||
load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
|
||||
VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 }
|
||||
IndexBinding { resource: string required, format: "uint16"|"uint32" = "uint32", offset: u64 = 0 }
|
||||
Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1,
|
||||
firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0,
|
||||
firstInstance: u32 = 0 }
|
||||
```
|
||||
|
||||
Render passes use `draw`; when `indexBuffer` exists, the indexed path uses `indices` (so set it nonzero), `firstIndex`, and `baseVertex`. Otherwise it uses `vertices` and `firstVertex`. Both use instance fields. Compute passes use `dispatch`. A pipeline ID is validated against the pass kind when the loadout is built. Binding offset/size and vertex/index offsets are bytes and must satisfy WebGPU alignment and range rules. Attachments require matching usages and formats; sampled/storage bindings likewise require matching texture usage, while WGSL determines binding type and visibility.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Worker message reference
|
||||
|
||||
All fields below are top-level beside `type` and `request`. Except for `init` and `upload-texture`, transfer lists are empty.
|
||||
|
||||
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|
||||
|---|---|---|---|---|
|
||||
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[] }` | Once only; initializes WASM/WebGPU. |
|
||||
| `create-rows` | `name`, `rows`, `stride`, `format` | — | descriptor | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. |
|
||||
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | descriptor array in input order | Nonempty. Sequential, **not transactional**: earlier creations can survive a later failure. Active GPU resources refresh once after successful batch. |
|
||||
| `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. |
|
||||
| `allocate-object` | `name` | — | `{ id, rows: descriptor }` | Not `signals`; may grow/relocate. |
|
||||
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must currently be active; row is zeroed. |
|
||||
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |
|
||||
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. |
|
||||
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |
|
||||
| `delete-texture` | `name` | — | `undefined` | Deletes/closes cached mip sources; does not remove graph texture or mark dirty. |
|
||||
| `play` | — | — | `undefined` | Enables render-loop ticks; resets last-time baseline. |
|
||||
| `pause` | — | — | `undefined` | Stops loop updates/renders. |
|
||||
| `set-fps` | `fps: u32` | — | `undefined` | 0 uncapped; maximum 1000. Does not itself dirty a frame. |
|
||||
| `set-profiler` | `enabled` (boolean-coerced) | — | boolean | Result says timestamp queries are supported. Enables only when requested and supported. |
|
||||
|
||||
Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion.
|
||||
|
||||
## Profiler events
|
||||
|
||||
When enabled and supported, the worker polls every 250 ms and may send an unsolicited message with **no request field**:
|
||||
|
||||
```js
|
||||
{
|
||||
type: "profile",
|
||||
stats: {
|
||||
frame: 42,
|
||||
milliseconds: 0.31,
|
||||
readbackMilliseconds: 4.8,
|
||||
adapter: "Adapter name · DeviceType · Backend",
|
||||
canvas: { width: 1280, height: 720 },
|
||||
passes: [{ name: "triangle", milliseconds: 0.31 }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
One pass timing corresponds to a compiled execution; compatible adjacent render passes may be merged and labeled together. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval.
|
||||
|
||||
## Texture lifecycle
|
||||
|
||||
Create an `ImageBitmap`, then relinquish it to the worker:
|
||||
|
||||
```js
|
||||
const image = await createImageBitmap(blob);
|
||||
await request("upload-texture", { name: "albedo", mipLevel: 0, image }, [image]);
|
||||
```
|
||||
|
||||
The name is a graph texture ID, not an arbitrary row name. Upload can precede graph activation because sources are cached. Non-transient textures with an unchanged descriptor can be retained across switches; cached levels are reapplied when needed. `delete-texture` only removes these cached sources.
|
||||
+605
-41
@@ -1,62 +1,626 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
||||
import * as Handles from "@yawn/handles";
|
||||
import { YawnCore } from "@yawn/core";
|
||||
import { loadGraph } from "@yawn/render-graph-js";
|
||||
import { triangleGraph } from "@yawn/default-pipelines";
|
||||
import { playgrounds } from "./playgrounds";
|
||||
|
||||
const props = defineProps({
|
||||
example: { type: String, default: "triangle" },
|
||||
fullscreen: { type: Boolean, default: false },
|
||||
});
|
||||
const examples = Object.entries(playgrounds);
|
||||
const selected = ref(playgrounds[props.example] ? props.example : "triangle");
|
||||
const preset = computed(() => playgrounds[selected.value]);
|
||||
const canvas = ref();
|
||||
const editor = ref();
|
||||
const preview = ref();
|
||||
const source = ref(preset.value.code);
|
||||
const status = ref("Starting…");
|
||||
const output = ref([]);
|
||||
const failed = ref(false);
|
||||
let core;
|
||||
let color;
|
||||
const running = ref(false);
|
||||
const canvasKey = ref(0);
|
||||
const fps = ref(0);
|
||||
const profilerOpen = ref(false);
|
||||
const profilerSupported = ref(null);
|
||||
const profile = ref(null);
|
||||
const adapterInfo = ref("");
|
||||
let generation = 0;
|
||||
let current;
|
||||
let stopProfile;
|
||||
let fpsFrame = 0;
|
||||
let sampledFrame = 0;
|
||||
let sampledAt = 0;
|
||||
let editorView;
|
||||
|
||||
function move(event) {
|
||||
if (!color) return;
|
||||
const bounds = canvas.value.getBoundingClientRect();
|
||||
const row = color.row(0);
|
||||
row[0] = (event.clientX - bounds.left) / bounds.width;
|
||||
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const api = { ...Handles, YawnCore };
|
||||
|
||||
function updateSource(value) {
|
||||
source.value = value;
|
||||
if (editorView && editorView.state.doc.toString() !== value) {
|
||||
editorView.dispatch({
|
||||
changes: { from: 0, to: editorView.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled");
|
||||
canvas.value.width = 960;
|
||||
canvas.value.height = 540;
|
||||
core = new YawnCore(canvas.value);
|
||||
await core.ready;
|
||||
color = await core.allocateRows({
|
||||
name: "triangle.color",
|
||||
rows: 1,
|
||||
stride: 16,
|
||||
format: "f32",
|
||||
});
|
||||
color.write(0, [0.2, 0.65, 1, 1]);
|
||||
await loadGraph(core, triangleGraph());
|
||||
window.__yawnPlayground = { core, color };
|
||||
status.value = "Running · move the pointer to write the shared color row";
|
||||
} catch (error) {
|
||||
failed.value = true;
|
||||
status.value = error.message;
|
||||
function requestedSave() {
|
||||
if (!props.fullscreen) return selected.value;
|
||||
const save = new URL(location.href).searchParams.get("save");
|
||||
return save && playgrounds[save] ? save : "triangle";
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
function openSave(event, save, push = true) {
|
||||
event?.preventDefault();
|
||||
selected.value = playgrounds[save] ? save : "triangle";
|
||||
updateSource(preset.value.code);
|
||||
if (props.fullscreen && push) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("save", selected.value);
|
||||
history.pushState(null, "", url);
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
function restoreSave() {
|
||||
openSave(undefined, requestedSave(), false);
|
||||
}
|
||||
|
||||
async function dispose(value = current) {
|
||||
if (!value) return;
|
||||
if (value === current) {
|
||||
stopProfile?.();
|
||||
stopProfile = undefined;
|
||||
current = undefined;
|
||||
delete window.__yawnPlayground;
|
||||
core?.dispose();
|
||||
}
|
||||
if (typeof value === "function") await value();
|
||||
else if (typeof value.dispose === "function") await value.dispose();
|
||||
}
|
||||
|
||||
async function attachProfiler(runId = generation) {
|
||||
stopProfile?.();
|
||||
stopProfile = undefined;
|
||||
profile.value = null;
|
||||
profilerSupported.value = null;
|
||||
if (!profilerOpen.value) return;
|
||||
if (!adapterInfo.value) {
|
||||
const adapter = await navigator.gpu?.requestAdapter({
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
const info = adapter?.info;
|
||||
adapterInfo.value = [
|
||||
info?.vendor,
|
||||
info?.architecture,
|
||||
info?.device,
|
||||
info?.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
const core = current?.scene?.core ?? current?.core;
|
||||
if (!core?.onProfile || !core?.setProfiler) {
|
||||
profilerSupported.value = false;
|
||||
return;
|
||||
}
|
||||
stopProfile = core.onProfile((stats) => {
|
||||
if (runId === generation) profile.value = stats;
|
||||
});
|
||||
profilerSupported.value = await core.setProfiler(true);
|
||||
}
|
||||
|
||||
async function toggleProfiler() {
|
||||
profilerOpen.value = !profilerOpen.value;
|
||||
if (profilerOpen.value) {
|
||||
await attachProfiler();
|
||||
} else {
|
||||
stopProfile?.();
|
||||
stopProfile = undefined;
|
||||
profile.value = null;
|
||||
const core = current?.scene?.core ?? current?.core;
|
||||
await core?.setProfiler?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const runId = ++generation;
|
||||
running.value = true;
|
||||
failed.value = false;
|
||||
output.value = [];
|
||||
status.value = "Running…";
|
||||
try {
|
||||
await dispose();
|
||||
if (!crossOriginIsolated)
|
||||
throw new Error("Cross-origin isolation is disabled");
|
||||
canvasKey.value++;
|
||||
await nextTick();
|
||||
canvas.value.width = props.fullscreen
|
||||
? Math.max(1, preview.value.clientWidth)
|
||||
: 960;
|
||||
canvas.value.height = props.fullscreen
|
||||
? Math.max(1, preview.value.clientHeight)
|
||||
: 540;
|
||||
const executable = source.value.replace(/^\s*import\s+[^;]+;\s*$/gm, "");
|
||||
const names = Object.keys(api);
|
||||
const started = performance.now();
|
||||
const result = await new AsyncFunction(
|
||||
...names,
|
||||
"canvas",
|
||||
"log",
|
||||
executable,
|
||||
)(...names.map((name) => api[name]), canvas.value, (message) =>
|
||||
runId === generation && output.value.push(String(message)),
|
||||
);
|
||||
if (runId !== generation) {
|
||||
await dispose(result);
|
||||
return;
|
||||
}
|
||||
current = result;
|
||||
window.__yawnPlayground = result;
|
||||
await attachProfiler(runId);
|
||||
status.value = `Running · ${Math.round(performance.now() - started)} ms`;
|
||||
} catch (error) {
|
||||
if (runId === generation) {
|
||||
failed.value = true;
|
||||
status.value = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
if (runId === generation) running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
updateSource(preset.value.code);
|
||||
run();
|
||||
}
|
||||
|
||||
function sampleFps(time = performance.now()) {
|
||||
try {
|
||||
const core = current?.scene?.core ?? current?.core;
|
||||
if (!core) throw new Error();
|
||||
const frame = Number(core.array("signals").row(0)[1]);
|
||||
if (frame < sampledFrame) sampledAt = 0;
|
||||
if (!sampledAt) {
|
||||
sampledFrame = frame;
|
||||
sampledAt = time;
|
||||
} else if (time - sampledAt >= 500) {
|
||||
const measured = ((frame - sampledFrame) * 1000) / (time - sampledAt);
|
||||
fps.value = measured < 10 ? measured.toFixed(1) : Math.round(measured);
|
||||
sampledFrame = frame;
|
||||
sampledAt = time;
|
||||
}
|
||||
} catch {
|
||||
fps.value = 0;
|
||||
sampledFrame = 0;
|
||||
sampledAt = time;
|
||||
}
|
||||
fpsFrame = requestAnimationFrame(sampleFps);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
selected.value = requestedSave();
|
||||
source.value = preset.value.code;
|
||||
editorView = new EditorView({
|
||||
doc: source.value,
|
||||
parent: editor.value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
javascript(),
|
||||
oneDark,
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({
|
||||
"aria-label": "Editable playground code",
|
||||
spellcheck: "false",
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) source.value = update.state.doc.toString();
|
||||
}),
|
||||
],
|
||||
});
|
||||
if (props.fullscreen) addEventListener("popstate", restoreSave);
|
||||
sampleFps();
|
||||
run();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
generation++;
|
||||
removeEventListener("popstate", restoreSave);
|
||||
editorView?.destroy();
|
||||
cancelAnimationFrame(fpsFrame);
|
||||
dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="playground">
|
||||
<canvas ref="canvas" aria-label="Yawn WebGPU output" @pointermove="move" />
|
||||
<p :class="{ failed }" data-playground-status>{{ status }}</p>
|
||||
<section
|
||||
class="playground"
|
||||
:class="{ fullscreen }"
|
||||
:aria-label="`${preset.title} playground`"
|
||||
>
|
||||
<header>
|
||||
<a v-if="fullscreen" class="brand" href="/">Yawn</a>
|
||||
<strong>{{ preset.title }}</strong>
|
||||
<span :class="{ failed }" data-playground-status>{{ status }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="running"
|
||||
@click="reset"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
v-if="fullscreen"
|
||||
type="button"
|
||||
class="secondary"
|
||||
:aria-pressed="profilerOpen"
|
||||
data-profiler-toggle
|
||||
@click="toggleProfiler"
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<button type="button" :disabled="running" @click="run">
|
||||
{{ running ? "Running…" : "Run" }}
|
||||
</button>
|
||||
</header>
|
||||
<nav v-if="fullscreen" class="saves" aria-label="Saved playgrounds">
|
||||
<span>Saved</span>
|
||||
<a
|
||||
v-for="([save, item]) in examples"
|
||||
:key="save"
|
||||
:href="`/playground?save=${save}`"
|
||||
:aria-current="save === selected ? 'page' : undefined"
|
||||
@click="openSave($event, save)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</nav>
|
||||
<div class="workspace">
|
||||
<div ref="editor" class="editor" />
|
||||
<div ref="preview" class="preview">
|
||||
<canvas :key="canvasKey" ref="canvas" aria-label="Yawn WebGPU output" />
|
||||
<aside v-if="profilerOpen" class="profiler" data-playground-profiler>
|
||||
<div class="profiler-title">
|
||||
<strong>GPU passes</strong>
|
||||
<span v-if="profile">{{ profile.milliseconds.toFixed(2) }} ms</span>
|
||||
</div>
|
||||
<div v-if="profile" class="profiler-meta">
|
||||
<span>{{ adapterInfo || profile.adapter }}</span>
|
||||
<span>
|
||||
{{ profile.canvas.width }}×{{ profile.canvas.height }} ·
|
||||
{{ profile.readbackMilliseconds.toFixed(2) }} ms readback latency
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="profilerSupported === false">
|
||||
Timestamp queries are unavailable on this GPU.
|
||||
</p>
|
||||
<p v-else-if="!profile">Waiting for a completed frame…</p>
|
||||
<table v-else>
|
||||
<tbody>
|
||||
<tr v-for="(pass, index) in profile.passes" :key="`${index}-${pass.name}`">
|
||||
<th>{{ pass.name }}</th>
|
||||
<td>{{ pass.milliseconds.toFixed(2) }} ms</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</aside>
|
||||
<div v-if="output.length" class="output" data-playground-log>
|
||||
<div v-for="(line, index) in output" :key="index">{{ line }}</div>
|
||||
</div>
|
||||
<div class="fps" data-playground-fps>{{ fps }} FPS</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playground { margin: 24px 0; }
|
||||
canvas { width: 100%; aspect-ratio: 16 / 9; display: block; background: #111827; border-radius: 12px; }
|
||||
p { color: var(--vp-c-text-2); }
|
||||
.failed { color: var(--vp-c-danger-1); }
|
||||
.playground {
|
||||
position: relative;
|
||||
left: 50%;
|
||||
width: min(1120px, calc(100vw - 64px));
|
||||
margin: 28px 0 36px;
|
||||
overflow: hidden;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: #0b1020;
|
||||
box-shadow: var(--vp-shadow-3);
|
||||
}
|
||||
.playground.fullscreen {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
inset: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
:global(.VPContent.has-sidebar .playground) {
|
||||
left: calc(50% + var(--vp-sidebar-width) / 2);
|
||||
width: min(1120px, calc(100vw - var(--vp-sidebar-width) - 64px));
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
:global(.VPDoc.has-sidebar.has-aside .playground) {
|
||||
left: 50%;
|
||||
width: min(1120px, calc(100vw - var(--vp-sidebar-width) - 288px));
|
||||
}
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 7px 10px 7px 16px;
|
||||
color: #dbeafe;
|
||||
border-bottom: 1px solid #263249;
|
||||
background: #111827;
|
||||
}
|
||||
header strong {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand {
|
||||
padding-right: 12px;
|
||||
color: #60a5fa;
|
||||
border-right: 1px solid #334155;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
header span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: #94a3b8;
|
||||
font:
|
||||
12px/1.4 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
monospace;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button {
|
||||
padding: 6px 14px;
|
||||
color: white;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #2563eb;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.secondary {
|
||||
color: #cbd5e1;
|
||||
background: #293449;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.saves {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 10px;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #263249;
|
||||
background: #0d1424;
|
||||
font:
|
||||
11px/1 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.saves span {
|
||||
margin-right: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.saves a {
|
||||
padding: 5px 7px;
|
||||
color: #94a3b8;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.saves a:hover {
|
||||
color: #e2e8f0;
|
||||
background: #1e293b;
|
||||
}
|
||||
.saves a[aria-current="page"] {
|
||||
color: #bfdbfe;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 510px;
|
||||
}
|
||||
.fullscreen .workspace {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.editor {
|
||||
min-width: 0;
|
||||
height: 510px;
|
||||
border-right: 1px solid #263249;
|
||||
background: #0b1020;
|
||||
}
|
||||
.fullscreen .editor {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
background: #0b1020;
|
||||
font:
|
||||
13px/1.55 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace;
|
||||
}
|
||||
.editor :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
font-family: inherit;
|
||||
}
|
||||
.editor :deep(.cm-gutters) {
|
||||
color: #526079;
|
||||
border-right-color: #263249;
|
||||
background: #0d1424;
|
||||
}
|
||||
.editor :deep(.cm-focused) {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px #2563eb;
|
||||
}
|
||||
.preview {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #050914;
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
.output {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 48px;
|
||||
left: 12px;
|
||||
max-height: 30%;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
color: #bfdbfe;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
background: rgb(2 6 23 / 82%);
|
||||
font:
|
||||
11px/1.45 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
monospace;
|
||||
}
|
||||
.profiler {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 72%);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #cbd5e1;
|
||||
border-left: 1px solid #334155;
|
||||
background: rgb(2 6 23 / 94%);
|
||||
font:
|
||||
12px/1.45 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
monospace;
|
||||
}
|
||||
.profiler-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.profiler-title span {
|
||||
color: #60a5fa;
|
||||
font-weight: 700;
|
||||
}
|
||||
.profiler-meta {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: -5px 0 12px;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.profiler p {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.profiler table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.profiler th,
|
||||
.profiler td {
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
.profiler th {
|
||||
overflow: hidden;
|
||||
max-width: 190px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.profiler td {
|
||||
color: #93c5fd;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fps {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
padding: 5px 9px;
|
||||
color: #dbeafe;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 999px;
|
||||
background: rgb(2 6 23 / 82%);
|
||||
font:
|
||||
700 12px/1 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
monospace;
|
||||
}
|
||||
.failed {
|
||||
color: #fca5a5;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.playground,
|
||||
:global(.VPContent.has-sidebar .playground) {
|
||||
left: auto;
|
||||
width: 100%;
|
||||
transform: none;
|
||||
}
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editor {
|
||||
height: 360px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #263249;
|
||||
}
|
||||
.fullscreen .editor {
|
||||
height: 45vh;
|
||||
min-height: 240px;
|
||||
}
|
||||
.preview {
|
||||
min-height: 360px;
|
||||
}
|
||||
.fullscreen .preview {
|
||||
min-height: 0;
|
||||
}
|
||||
header strong {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,9 +23,29 @@ export default defineConfig({
|
||||
cleanUrls: true,
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "Architecture", link: "/" },
|
||||
{ text: "Guide", link: "/guide/getting-started" },
|
||||
{ text: "Core", link: "/guide/core" },
|
||||
{ text: "Playground", link: "/playground" },
|
||||
],
|
||||
sidebar: {
|
||||
"/guide/": [
|
||||
{
|
||||
text: "Learn Yawn",
|
||||
items: [
|
||||
{ text: "Getting started", link: "/guide/getting-started" },
|
||||
{ text: "Scene and shared data", link: "/guide/scene-and-sab" },
|
||||
{ text: "Cameras and controls", link: "/guide/cameras" },
|
||||
{ text: "Meshes and instances", link: "/guide/meshes-and-instances" },
|
||||
{ text: "Materials and textures", link: "/guide/materials" },
|
||||
{ text: "Clustered lights", link: "/guide/lights" },
|
||||
{ text: "Compute passes", link: "/guide/compute" },
|
||||
{ text: "Post processing", link: "/guide/post-processing" },
|
||||
{ text: "glTF and picking", link: "/guide/importing-and-picking" },
|
||||
{ text: "Core boundary", link: "/guide/core" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
plugins: [isolation],
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
const triangle = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
|
||||
const material = new PBRMaterial(scene, {
|
||||
baseColor: [0.15, 0.55, 1, 1],
|
||||
metallic: 0.15,
|
||||
roughness: 0.4,
|
||||
});
|
||||
await material.ready;
|
||||
|
||||
const mesh = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
log("Move the pointer to write sceneAccent directly in the SAB.");
|
||||
const move = (event) => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
scene.array("sceneAccent").row(0).set([
|
||||
(event.clientX - bounds.left) / bounds.width,
|
||||
1 - (event.clientY - bounds.top) / bounds.height,
|
||||
1,
|
||||
1,
|
||||
]);
|
||||
};
|
||||
canvas.addEventListener("pointermove", move);
|
||||
|
||||
return {
|
||||
scene,
|
||||
mesh,
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointermove", move);
|
||||
scene.dispose();
|
||||
},
|
||||
};`;
|
||||
|
||||
const sab = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
const material = new PBRMaterial(scene, { baseColor: [0.2, 0.9, 0.65, 1] });
|
||||
await material.ready;
|
||||
const mesh = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.35, -0.35, 0, 0.35, -0.35, 0, 0, 0.42, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
const velocity = await scene.ensureRows("app.velocity", 1, 16, "f32");
|
||||
velocity.row(0).set([0.8, 0, 0, 0]);
|
||||
log("Pointer movement mutates nodePositions; no worker message is sent.");
|
||||
|
||||
const move = (event) => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
mesh.position.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.4;
|
||||
mesh.position.y = (0.5 - (event.clientY - bounds.top) / bounds.height) * 1.2;
|
||||
};
|
||||
canvas.addEventListener("pointermove", move);
|
||||
|
||||
return {
|
||||
scene,
|
||||
mesh,
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointermove", move);
|
||||
scene.dispose();
|
||||
},
|
||||
};`;
|
||||
|
||||
const cameras = `import { ArcRotateCamera, Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
const material = new PBRMaterial(scene, { baseColor: [0.9, 0.3, 0.18, 1] });
|
||||
await material.ready;
|
||||
const mesh = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
const camera = new ArcRotateCamera(scene, {
|
||||
target: mesh,
|
||||
alpha: 0,
|
||||
beta: Math.PI / 2,
|
||||
radius: 3,
|
||||
fov: Math.PI / 3,
|
||||
near: 0.05,
|
||||
far: 100,
|
||||
aspect: canvas.width / canvas.height,
|
||||
controls: { element: canvas, pointer: true, controller: true },
|
||||
});
|
||||
await camera.ready;
|
||||
log("Drag to orbit, right-drag to pan, and wheel to zoom.");
|
||||
|
||||
return {
|
||||
scene,
|
||||
mesh,
|
||||
camera,
|
||||
async dispose() {
|
||||
await camera.dispose();
|
||||
scene.dispose();
|
||||
},
|
||||
};`;
|
||||
|
||||
const instances = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
const material = new PBRMaterial(scene, { baseColor: [0.25, 0.8, 1, 1] });
|
||||
await material.ready;
|
||||
const source = new Mesh(scene, {
|
||||
position: [-0.48, 0, 0],
|
||||
scale: [0.58, 0.58, 1],
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await source.ready;
|
||||
|
||||
const instance = source.clone({ position: [0.48, 0, 0], scale: [0.58, 0.58, 1] });
|
||||
await instance.ready;
|
||||
log(\`Two mesh handles share geometry #\${source.geometryId}.\`);
|
||||
|
||||
return { scene, source, instance, dispose: () => scene.dispose() };`;
|
||||
|
||||
const materials = `import { Mesh, PBRMaterial, Scene, Texture } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
const pixels = new OffscreenCanvas(64, 64);
|
||||
const context = pixels.getContext("2d");
|
||||
context.fillStyle = "#ff3018";
|
||||
context.fillRect(0, 0, 64, 64);
|
||||
context.fillStyle = "#ffd166";
|
||||
context.fillRect(0, 0, 32, 32);
|
||||
context.fillRect(32, 32, 32, 32);
|
||||
const albedo = new Texture(scene, {
|
||||
source: pixels.transferToImageBitmap(),
|
||||
format: "rgba8unorm-srgb",
|
||||
});
|
||||
await albedo.ready;
|
||||
const paint = new PBRMaterial(scene, {
|
||||
baseColor: [1, 1, 1, 1],
|
||||
baseColorTexture: albedo,
|
||||
metallic: 0.75,
|
||||
roughness: 0.18,
|
||||
});
|
||||
await paint.ready;
|
||||
const mesh = new Mesh(scene, {
|
||||
material: paint,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||
uvs: [0, 0, 2, 0, 1, 2],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
log("Move horizontally to mutate material roughness in shared memory.");
|
||||
const move = (event) => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
paint.roughness = (event.clientX - bounds.left) / bounds.width;
|
||||
};
|
||||
canvas.addEventListener("pointermove", move);
|
||||
|
||||
return {
|
||||
scene,
|
||||
mesh,
|
||||
paint,
|
||||
albedo,
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointermove", move);
|
||||
scene.dispose();
|
||||
},
|
||||
};`;
|
||||
|
||||
const lights = `import { AmbientLight, Mesh, PBRMaterial, PointLight, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
const material = new PBRMaterial(scene, { baseColor: [1, 0.45, 0.08, 1], roughness: 0.35 });
|
||||
await material.ready;
|
||||
const mesh = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
const point = new PointLight(scene, {
|
||||
position: [0, 0.5, 0.5],
|
||||
color: [1, 0.18, 0.04],
|
||||
intensity: 12,
|
||||
range: 8,
|
||||
});
|
||||
const ambient = new AmbientLight(scene, { color: [0.04, 0.12, 0.3], intensity: 0.35 });
|
||||
await Promise.all([point.ready, ambient.ready]);
|
||||
log("Point and ambient rows are consumed by the clustered compute pass.");
|
||||
|
||||
return { scene, mesh, point, ambient, dispose: () => scene.dispose() };`;
|
||||
|
||||
const compute = `import { ComputePass, Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
const material = new PBRMaterial(scene, { baseColor: [0.15, 0.75, 1, 1] });
|
||||
await material.ready;
|
||||
const mesh = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
const values = await scene.ensureRows("simulation.values", 1, 16, "u32");
|
||||
const simulation = new ComputePass({
|
||||
id: "increment",
|
||||
code: "@group(0) @binding(0) var<storage, read_write> values: array<u32>; @compute @workgroup_size(1) fn main() { values[0] += 1u; }",
|
||||
buffers: [{ id: "simulation-values", array: "simulation.values", usage: ["storage"] }],
|
||||
bindings: [{ group: 0, binding: 0, resource: "simulation-values" }],
|
||||
});
|
||||
await scene.addComputePass(simulation);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
log(\`Compute wrote \${values.row(0)[0]} into the SAB-backed buffer.\`);
|
||||
|
||||
return { scene, mesh, simulation, dispose: () => scene.dispose() };`;
|
||||
|
||||
const post = `import { ColorGrading, DynamicExposure, FXAA, Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
const material = new PBRMaterial(scene, { baseColor: [0.8, 0.18, 1, 1] });
|
||||
await material.ready;
|
||||
const mesh = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
let exposure, grade, fxaa;
|
||||
await scene.batchGraphUpdates(async () => {
|
||||
exposure = new DynamicExposure(scene, { exposure: 1.25 });
|
||||
grade = new ColorGrading(scene, { toneMap: "aces", amount: 1 });
|
||||
fxaa = new FXAA(scene);
|
||||
await Promise.all([exposure.ready, grade.ready, fxaa.ready]);
|
||||
});
|
||||
log("HDR → exposure → color grading → FXAA → canvas");
|
||||
|
||||
return { scene, mesh, exposure, grade, fxaa, dispose: () => scene.dispose() };`;
|
||||
|
||||
const importing = `import { AmbientLight, ArcRotateCamera, PointLight, Picking, Scene, importGltf } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true, arenaBytes: 384 * 1024 * 1024 });
|
||||
await scene.ready;
|
||||
scene.array("sceneAccent").row(0).set([1, 1, 1, 1]);
|
||||
log("Importing /models/sponza.glb in the importer worker…");
|
||||
const meshes = await importGltf(scene, "/models/sponza.glb");
|
||||
|
||||
// Add a warm ambient/key/fill setup without changing the authored transforms.
|
||||
const lights = [
|
||||
new AmbientLight(scene, {
|
||||
color: [1, 0.93, 0.82],
|
||||
intensity: 0.18,
|
||||
}),
|
||||
new PointLight(scene, {
|
||||
position: [-10, 14, -4],
|
||||
color: [1, 0.72, 0.5],
|
||||
intensity: 2,
|
||||
range: 28,
|
||||
}),
|
||||
new PointLight(scene, {
|
||||
position: [9, 10, 6],
|
||||
color: [1, 0.9, 0.75],
|
||||
intensity: 1.5,
|
||||
range: 24,
|
||||
}),
|
||||
];
|
||||
await Promise.all(lights.map((light) => light.ready));
|
||||
|
||||
const target = [0, 8, 0];
|
||||
const camera = new ArcRotateCamera(scene, {
|
||||
targetPosition: target,
|
||||
alpha: Math.PI / 2,
|
||||
beta: Math.PI / 3,
|
||||
radius: 30,
|
||||
near: 1,
|
||||
far: 1000,
|
||||
aspect: canvas.width / canvas.height,
|
||||
controls: { element: canvas, pointer: true },
|
||||
});
|
||||
await camera.ready;
|
||||
|
||||
const picking = new Picking(scene);
|
||||
await picking.ready;
|
||||
const pick = async () => {
|
||||
const origin = Array.from(camera.position);
|
||||
const direction = target.map((value, lane) => value - origin[lane]);
|
||||
const length = Math.hypot(...direction);
|
||||
const hits = await picking.pick(origin, direction.map((value) => value / length));
|
||||
log(\`Imported \${meshes.length} primitives; BVH ray returned \${hits.length} hit(s).\`);
|
||||
};
|
||||
canvas.addEventListener("click", pick);
|
||||
await pick();
|
||||
|
||||
return {
|
||||
scene,
|
||||
meshes,
|
||||
lights,
|
||||
camera,
|
||||
picking,
|
||||
async dispose() {
|
||||
canvas.removeEventListener("click", pick);
|
||||
picking.dispose();
|
||||
await camera.dispose();
|
||||
scene.dispose();
|
||||
},
|
||||
};`;
|
||||
|
||||
const benchmark = `import { ArcRotateCamera, Mesh, PBRMaterial, Scene, Texture } from "@yawn/handles";
|
||||
|
||||
const parameters = new URL(location.href).searchParams;
|
||||
const draws = Math.max(1, Math.min(512, Number(parameters.get("draws")) || 138));
|
||||
const grid = Math.max(1, Math.min(256, Number(parameters.get("grid")) || 128));
|
||||
const overdraw = parameters.has("overdraw");
|
||||
const columns = 12;
|
||||
const positions = [];
|
||||
const normals = [];
|
||||
const uvs = [];
|
||||
const indices = [];
|
||||
for (let y = 0; y <= grid; y++) {
|
||||
for (let x = 0; x <= grid; x++) {
|
||||
positions.push(x / grid * 2 - 1, y / grid * 2 - 1, 0);
|
||||
normals.push(0, 0, 1);
|
||||
uvs.push(x / grid * 32, y / grid * 32);
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < grid; y++) {
|
||||
for (let x = 0; x < grid; x++) {
|
||||
const first = y * (grid + 1) + x;
|
||||
indices.push(
|
||||
first, first + 1, first + grid + 2,
|
||||
first, first + grid + 2, first + grid + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pixels = new OffscreenCanvas(1024, 1024);
|
||||
const context = pixels.getContext("2d");
|
||||
for (let y = 0; y < 64; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
context.fillStyle = (x + y) % 2 ? "#e2e8f0" : "#172554";
|
||||
context.fillRect(x * 16, y * 16, 16, 16);
|
||||
}
|
||||
}
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true });
|
||||
await scene.ready;
|
||||
log("Benchmark core ready; preparing geometry…");
|
||||
const camera = new ArcRotateCamera(scene, {
|
||||
targetPosition: [0, 0, 0],
|
||||
alpha: 0,
|
||||
beta: Math.PI / 2,
|
||||
radius: 3,
|
||||
aspect: canvas.width / canvas.height,
|
||||
});
|
||||
await camera.ready;
|
||||
log("Benchmark camera ready; compiling graph…");
|
||||
let material;
|
||||
let source;
|
||||
const meshes = [];
|
||||
await scene.batchGraphUpdates(async () => {
|
||||
const texture = new Texture(scene, {
|
||||
source: pixels.transferToImageBitmap(),
|
||||
format: "rgba8unorm-srgb",
|
||||
});
|
||||
await texture.ready;
|
||||
material = new PBRMaterial(scene, {
|
||||
baseColorTexture: texture,
|
||||
roughness: 0.45,
|
||||
});
|
||||
await material.ready;
|
||||
source = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: { positions, normals, uvs, indices },
|
||||
});
|
||||
await source.ready;
|
||||
meshes.push(source);
|
||||
for (let index = 1; index < draws; index++) meshes.push(source.clone());
|
||||
await Promise.all(meshes.slice(1).map((mesh) => mesh.ready));
|
||||
for (let index = 0; index < meshes.length; index++) {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
meshes[index].position = overdraw
|
||||
? [0, 0, index * 0.01]
|
||||
: [
|
||||
-1 + (column + 0.5) * 2 / columns,
|
||||
1 - (row + 0.5) * 2 / columns,
|
||||
0,
|
||||
];
|
||||
meshes[index].scale = overdraw
|
||||
? [0.9, 0.9, 1]
|
||||
: [0.9 / columns, 0.9 / columns, 1];
|
||||
}
|
||||
});
|
||||
|
||||
const triangles = draws * grid * grid * 2;
|
||||
log(\`Deterministic \${overdraw ? "overdraw" : "geometry"} benchmark: \${draws} draws, \${triangles.toLocaleString()} triangles, 1024² minified texture.\`);
|
||||
log("Open Profile and compare Forward GPU time, not page load time.");
|
||||
|
||||
return { scene, meshes, material, camera, dispose: () => scene.dispose() };`;
|
||||
|
||||
const core = `import { YawnCore } from "@yawn/core";
|
||||
|
||||
const encode = (value) => {
|
||||
if (value === null || ["boolean", "number"].includes(typeof value)) return String(value);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return \`(array\${value.map((item) => \` \${encode(item)}\`).join("")})\`;
|
||||
return \`(object\${Object.keys(value).sort().map((key) =>
|
||||
\` (field \${JSON.stringify(key)} \${encode(value[key])})\`).join("")})\`;
|
||||
};
|
||||
|
||||
const core = new YawnCore(canvas);
|
||||
await core.ready;
|
||||
const accent = await core.createRows({ name: "accent", rows: 1, stride: 16, format: "f32" });
|
||||
accent.write(0, [0.2, 0.75, 1, 1]);
|
||||
|
||||
const code = "struct Out { @builtin(position) position: vec4<f32> }; @group(0) @binding(0) var<uniform> color: vec4<f32>; @vertex fn vertex(@builtin(vertex_index) id: u32) -> Out { let points = array(vec2(-.7,-.6), vec2(.7,-.6), vec2(0.,.72)); var out: Out; out.position = vec4(points[id],0.,1.); return out; } @fragment fn fragment() -> @location(0) vec4<f32> { return color; }";
|
||||
const graph = {
|
||||
id: "direct-core",
|
||||
resources: { buffers: [{ id: "accent", array: "accent", usage: ["uniform"] }], textures: [], samplers: [] },
|
||||
pipelines: { render: [{ id: "triangle", code, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] } }], compute: [] },
|
||||
passes: [{ id: "triangle", type: "render", pipeline: "triangle", bindings: [{ group: 0, binding: 0, resource: "accent" }], color: [{ resource: "canvas", clear: [0.01, 0.02, 0.04, 1] }], draw: { vertices: 3 } }],
|
||||
};
|
||||
const id = await core.compileGraph(\`(yawn-graph 1 \${encode(graph)})\`);
|
||||
await core.switchLoadout(id);
|
||||
log("The canvas is rendered by a graph sent directly to the Rust/WASM core.");
|
||||
|
||||
return { core, accent, dispose: () => core.dispose() };`;
|
||||
|
||||
export const playgrounds = {
|
||||
triangle: { title: "First scene", code: triangle },
|
||||
sab: { title: "Direct shared-memory movement", code: sab },
|
||||
cameras: { title: "Arc rotate camera", code: cameras },
|
||||
instances: { title: "Geometry instances", code: instances },
|
||||
materials: { title: "PBR material", code: materials },
|
||||
lights: { title: "Clustered lights", code: lights },
|
||||
compute: { title: "Compute pass", code: compute },
|
||||
post: { title: "HDR post processing", code: post },
|
||||
importing: { title: "glTF import and BVH picking", code: importing },
|
||||
benchmark: { title: "Forward benchmark", code: benchmark },
|
||||
core: { title: "Direct core graph", code: core },
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
# Cameras and controls
|
||||
|
||||
Every camera allocates generic `cameras`, `cameraMatrices`, and transform rows. Camera handles precompute the view-projection matrix when lens or transform values change, so the forward vertex shader performs four dot products instead of rebuilding the camera projection for every vertex.
|
||||
|
||||
## Shared lens and projection controls
|
||||
|
||||
```ts
|
||||
const camera = new Camera(scene, {
|
||||
fov: Math.PI / 3,
|
||||
near: 0.05,
|
||||
far: 2000,
|
||||
focalLength: 50,
|
||||
aperture: 2.8,
|
||||
focusDistance: 8,
|
||||
});
|
||||
await camera.ready;
|
||||
|
||||
camera.projection = "orthographic";
|
||||
camera.orthoSize = 12;
|
||||
camera.projection = "perspective";
|
||||
```
|
||||
|
||||
`fov`, `aspect`, `near`, `far`, `orthoSize`, `focalLength`, `aperture`, `focusDistance`, and `sensorWidth` all write the camera's shared row.
|
||||
|
||||
## Arc rotate
|
||||
|
||||
```ts
|
||||
const orbit = new ArcRotateCamera(scene, {
|
||||
alpha: 0,
|
||||
beta: Math.PI / 3,
|
||||
radius: 6,
|
||||
target: mesh,
|
||||
controls: {
|
||||
element: canvas,
|
||||
pointer: true, // left orbit, right pan, wheel zoom
|
||||
controller: true, // sticks + triggers
|
||||
},
|
||||
});
|
||||
await orbit.ready;
|
||||
```
|
||||
|
||||
## Free spectator camera
|
||||
|
||||
```ts
|
||||
const free = new FreeCamera(scene, {
|
||||
position: [0, 1, 5],
|
||||
controls: {
|
||||
element: canvas,
|
||||
keyboard: true, // WASD + Space/Ctrl
|
||||
pointer: true, // click for pointer lock, mouse to look
|
||||
controller: true,
|
||||
speed: 6,
|
||||
},
|
||||
});
|
||||
await free.ready;
|
||||
```
|
||||
|
||||
## Follow a character
|
||||
|
||||
```ts
|
||||
const follow = new FollowCamera(scene, {
|
||||
target: player,
|
||||
distance: 5,
|
||||
height: 1.8,
|
||||
smoothing: 0.12,
|
||||
});
|
||||
await follow.ready;
|
||||
|
||||
follow.target = anotherPlayer;
|
||||
follow.distance = 7;
|
||||
follow.stop();
|
||||
follow.start();
|
||||
```
|
||||
|
||||
The input and follow loops never post camera updates to core: they mutate the position, rotor, camera, and derived matrix rows directly in shared memory.
|
||||
|
||||
<Playground example="cameras" />
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
# Compute passes
|
||||
|
||||
Compute shaders are graph data, not core code. Declare the shared rows/resources a shader needs and attach the pass to `Scene`.
|
||||
|
||||
```ts
|
||||
const velocity = await scene.ensureRows("velocity", 4096, 16, "f32");
|
||||
|
||||
const simulation = new ComputePass({
|
||||
id: "integrate",
|
||||
code: `
|
||||
@group(0) @binding(0)
|
||||
var<storage, read_write> velocity: array<vec4<f32>>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x < arrayLength(&velocity)) {
|
||||
velocity[id.x].y -= 0.001;
|
||||
}
|
||||
}
|
||||
`,
|
||||
buffers: [{ id: "velocity", array: "velocity", usage: ["storage"] }],
|
||||
bindings: [{ group: 0, binding: 0, resource: "velocity" }],
|
||||
dispatch: [64, 1, 1],
|
||||
});
|
||||
|
||||
await scene.addComputePass(simulation);
|
||||
```
|
||||
|
||||
<Playground example="compute" />
|
||||
|
||||
The graph DAG places an unqualified custom compute pass after light clustering and makes forward rendering depend on all custom compute passes. Use `after` to specify other dependencies.
|
||||
|
||||
```ts
|
||||
await simulation.update({ dispatch: [128, 1, 1] });
|
||||
await scene.removeComputePass(simulation);
|
||||
```
|
||||
|
||||
Both operations are infrequent graph/loadout messages. Existing source row changes are direct SAB writes.
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
# Core boundary
|
||||
|
||||
`@yawn/core` deliberately contains no scene types and no WGSL. It owns two things:
|
||||
|
||||
1. a 64-byte-aligned arena of named f32/u32/i32 SOA rows in one `SharedArrayBuffer`;
|
||||
2. render-graph compilation, up-front WebGPU loadouts, transient resource aliasing, render bundles, and the paced render loop.
|
||||
|
||||
```text
|
||||
infrequent worker messages hot shared mutations
|
||||
┌──────────────────────────────┐ ┌──────────────────────┐
|
||||
│ create/delete rows │ │ transforms │
|
||||
│ allocate/delete object slot │ │ cameras/materials │
|
||||
│ compile/switch graph │ │ lights/app data │
|
||||
│ play/pause/set FPS │ │ signals + row data │
|
||||
└──────────────┬───────────────┘ └──────────┬───────────┘
|
||||
└─────────────────┬─────────────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Rust/WASM core │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Use core directly
|
||||
|
||||
```ts
|
||||
import { YawnCore } from "@yawn/core";
|
||||
|
||||
const core = new YawnCore(canvas, { arenaBytes: 64 * 1024 * 1024 });
|
||||
await core.ready;
|
||||
|
||||
const values = await core.createRows({
|
||||
name: "application.values",
|
||||
rows: 1024,
|
||||
stride: 16,
|
||||
format: "f32",
|
||||
});
|
||||
|
||||
const id = await core.allocateObject("application.values");
|
||||
values.row(id).set([1, 2, 3, 4]);
|
||||
```
|
||||
|
||||
Graph frontends serialize plain data to `(yawn-graph 1 ...)`. Named `after` edges preserve DAG fan-out; Rust sorts passes, detects cycles, culls unused declarations, aliases compatible transient lifetimes, merges compatible render passes into bundles, and allocates the active loadout. Unchanged persistent textures survive loadout rebuilds.
|
||||
|
||||
The `Scene` addon is one such frontend. It is replaceable and has no privileged core API.
|
||||
|
||||
## Profile physical GPU passes
|
||||
|
||||
```ts
|
||||
const stop = core.onProfile((frame) => {
|
||||
console.table(frame.passes); // name + GPU milliseconds
|
||||
});
|
||||
const supported = await core.setProfiler(true);
|
||||
|
||||
// Later:
|
||||
await core.setProfiler(false);
|
||||
stop();
|
||||
```
|
||||
|
||||
`new YawnCore(canvas, { debug: true })` enables the same timestamp-query mode at startup. Samples are read back asynchronously, so profiling does not serialize the GPU queue. Timings describe physical GPU passes and actual compiled draw counts; compatible indexed draws over consecutive instances collapse into one command. The sidebar also reports canvas size and wall-clock readback latency.
|
||||
|
||||
The saved **Forward benchmark** playground renders 138 logical objects and 4.5 million triangles. Add `&grid=32` to lower geometry density or `&overdraw=1` to stack the objects while diagnosing depth and fragment cost.
|
||||
|
||||
<Playground example="core" />
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
# Getting started
|
||||
|
||||
Yawn separates the data/render engine from optional scene conventions. Most applications begin with `@yawn/handles`; specialized engines can use `@yawn/core` directly.
|
||||
|
||||
## 1. Serve with isolation headers
|
||||
|
||||
`SharedArrayBuffer` requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. The included VitePress server already sends both.
|
||||
|
||||
```html
|
||||
<canvas id="view"></canvas>
|
||||
<script type="module" src="/src/app.ts"></script>
|
||||
```
|
||||
|
||||
## 2. Start a Scene
|
||||
|
||||
```ts
|
||||
import { Scene } from "@yawn/handles";
|
||||
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#view")!;
|
||||
canvas.width = 1280;
|
||||
canvas.height = 720;
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
||||
await scene.ready;
|
||||
```
|
||||
|
||||
Omit `fps` to render as soon as shared data changes. `Scene` initializes conventional SOA rows and loads one clustered-forward HDR render graph. The core itself still starts with only its eight-float `signals` row.
|
||||
|
||||
## 3. Add a triangle
|
||||
|
||||
```ts
|
||||
import { Mesh, PBRMaterial } from "@yawn/handles";
|
||||
|
||||
const blue = new PBRMaterial(scene, {
|
||||
baseColor: [0.15, 0.55, 1, 1],
|
||||
metallic: 0.15,
|
||||
roughness: 0.4,
|
||||
});
|
||||
await blue.ready;
|
||||
|
||||
const triangle = new Mesh(scene, {
|
||||
material: blue,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await triangle.ready;
|
||||
```
|
||||
|
||||
Constructors use worker messages only to reserve slots or rebuild the graph. Once `ready` resolves, ordinary property writes mutate shared memory.
|
||||
|
||||
<Playground />
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
# glTF import and picking
|
||||
|
||||
## Import off-thread
|
||||
|
||||
The shared importer worker fetches and parses glTF/GLB, then the response hydrates `Mesh` and `PBRMaterial` handles attached to your scene.
|
||||
|
||||
```ts
|
||||
import { importGltf } from "@yawn/handles";
|
||||
|
||||
const meshes = await importGltf(scene, "/models/sponza.glb");
|
||||
meshes[0].position.y = 0.5;
|
||||
```
|
||||
|
||||
The importer handles triangle primitives, external/data buffers, standard vertex attributes, indices, node transforms, and metallic-roughness values. Application-specific extensions remain application policy.
|
||||
|
||||
## Pick in the BVH worker
|
||||
|
||||
```ts
|
||||
const picking = new Picking(scene);
|
||||
await picking.ready;
|
||||
|
||||
canvas.addEventListener("click", async () => {
|
||||
const hits = await picking.pick([0, 0, 4], [0, 0, -1]);
|
||||
const nearest = hits[0];
|
||||
if (nearest) console.log(nearest.id, nearest.distance);
|
||||
});
|
||||
```
|
||||
|
||||
The worker reads shared node positions and mesh bounds, updates its BVH when the shared frame counter changes, and returns **all** AABB hits sorted by distance. You can shortlist or run an exact test afterward.
|
||||
|
||||
If row allocations relocated since `Picking` was created, refresh its shared descriptors:
|
||||
|
||||
```ts
|
||||
await picking.refresh();
|
||||
```
|
||||
|
||||
The playground below imports the repository's full LFS-backed `sponza.glb` in the importer worker, preserves its authored transforms, adds neutral warm lighting, places an arc camera in its coordinate system, and sends a real ray to the BVH worker. Click the preview to pick again.
|
||||
|
||||
<Playground example="importing" />
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
# Clustered lights
|
||||
|
||||
The default `Scene` graph runs a clustered compute pass before its HDR forward passes. Light handles write flat rows consumed by that pass.
|
||||
|
||||
```ts
|
||||
const point = new PointLight(scene, {
|
||||
position: [0, 1, 1],
|
||||
color: [1, 0.25, 0.05],
|
||||
intensity: 20,
|
||||
range: 8,
|
||||
});
|
||||
|
||||
const sun = new DirectionalLight(scene, {
|
||||
rotor: [0.2, 0, 0, 0.98],
|
||||
color: [1, 0.95, 0.8],
|
||||
intensity: 3,
|
||||
});
|
||||
|
||||
const fill = new AmbientLight(scene, { color: [0.1, 0.2, 0.4], intensity: 0.2 });
|
||||
await Promise.all([point.ready, sun.ready, fill.ready]);
|
||||
```
|
||||
|
||||
<Playground example="lights" />
|
||||
|
||||
## Rectangles and spots
|
||||
|
||||
```ts
|
||||
const panel = new RectAreaLight(scene, {
|
||||
position: [0, 2, 0],
|
||||
width: 2,
|
||||
height: 0.5,
|
||||
intensity: 12,
|
||||
});
|
||||
|
||||
const spot = new SpotLight(scene, {
|
||||
position: [0, 1, 1],
|
||||
innerAngle: 0.25,
|
||||
outerAngle: 0.6,
|
||||
range: 15,
|
||||
});
|
||||
```
|
||||
|
||||
The rectangle handle selects the default graph's linearly transformed cosine (`ltc`) path. Position, orientation, intensity, angles, and colors remain direct SAB mutations after allocation.
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
# Materials and textures
|
||||
|
||||
`PBRMaterial` is a conventional view over `materials` and `materialTextures` SOA rows.
|
||||
|
||||
```ts
|
||||
const paint = new PBRMaterial(scene, {
|
||||
baseColor: [0.8, 0.05, 0.03, 1],
|
||||
metallic: 0.65,
|
||||
roughness: 0.22,
|
||||
emissive: [0, 0, 0],
|
||||
});
|
||||
await paint.ready;
|
||||
|
||||
paint.roughness = 0.5; // direct SAB write
|
||||
paint.baseColor[1] = 0.35; // direct SAB write
|
||||
mesh.material = paint;
|
||||
```
|
||||
|
||||
<Playground example="materials" />
|
||||
|
||||
## Graph textures
|
||||
|
||||
```ts
|
||||
const albedo = new Texture(scene, {
|
||||
source: "/textures/paint.png",
|
||||
format: "rgba8unorm-srgb",
|
||||
});
|
||||
await albedo.ready;
|
||||
|
||||
const textured = new PBRMaterial(scene, { baseColorTexture: albedo });
|
||||
```
|
||||
|
||||
Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The addon generates and transfers a complete mip chain by default; pass `mipmaps: false` only for data that must remain single-level. Compatible loadout rebuilds reuse the allocated GPU texture without uploading it again.
|
||||
|
||||
## Custom WGSL
|
||||
|
||||
`ShaderMaterial` adds its external WGSL pipeline to the scene graph. Updating the code updates the loadout.
|
||||
|
||||
```ts
|
||||
const shader = new ShaderMaterial(scene, {
|
||||
code: `
|
||||
struct Out { @builtin(position) position: vec4<f32> }
|
||||
@vertex fn vertex(@builtin(vertex_index) id: u32) -> Out {
|
||||
let p = array(vec2(-.2, -.2), vec2(.2, -.2), vec2(0., .2));
|
||||
var out: Out; out.position = vec4(p[id], 0., 1.); return out;
|
||||
}
|
||||
@fragment fn fragment() -> @location(0) vec4<f32> {
|
||||
return vec4(1., .2, .7, 1.);
|
||||
}
|
||||
`,
|
||||
});
|
||||
await shader.ready;
|
||||
```
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
# Meshes and instances
|
||||
|
||||
Every `Mesh` is an instance. `clone()` shares geometry and allocates only a new node/mesh slot.
|
||||
|
||||
```ts
|
||||
const source = new Mesh(scene, {
|
||||
vertexData: {
|
||||
positions: [-0.2, -0.2, 0, 0.2, -0.2, 0, 0, 0.25, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await source.ready;
|
||||
|
||||
for (let x = -4; x <= 4; x++) {
|
||||
const instance = source.clone({ position: [x * 0.2, 0, 0] });
|
||||
await instance.ready;
|
||||
}
|
||||
```
|
||||
|
||||
<Playground example="instances" />
|
||||
|
||||
## Copy-on-write geometry
|
||||
|
||||
The default vertex kinds are `positions`, `normals`, `tangents`, `uvs`, `colors`, and `indices`. Mutating any kind on an instanced clone first makes that mesh's geometry unique.
|
||||
|
||||
```ts
|
||||
const clone = source.clone();
|
||||
await clone.ready;
|
||||
|
||||
await clone.setVertexData("positions", [
|
||||
-0.4, -0.2, 0,
|
||||
0.4, -0.2, 0,
|
||||
0.0, 0.5, 0,
|
||||
]);
|
||||
```
|
||||
|
||||
## Per-face materials and visibility
|
||||
|
||||
```ts
|
||||
await mesh.setMaterialForFaces(red, [0, 2, 4]);
|
||||
mesh.material = blue;
|
||||
mesh.isVisible = false; // one direct u32 write
|
||||
```
|
||||
|
||||
Face rows store optional material pointers; a zero lane falls back to `mesh.material`.
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
# Post processing
|
||||
|
||||
Post-process handles insert or remove graph passes. Intermediate HDR textures are transient and compatible non-overlapping lifetimes alias the same physical allocation.
|
||||
|
||||
```ts
|
||||
const ssao = new SSAO(scene, { amount: 0.8 });
|
||||
const exposure = new DynamicExposure(scene, { exposure: 1.1 });
|
||||
const grade = new ColorGrading(scene, { toneMap: "aces", amount: 1.0 });
|
||||
const fxaa = new FXAA(scene);
|
||||
|
||||
await Promise.all([ssao.ready, exposure.ready, grade.ready, fxaa.ready]);
|
||||
```
|
||||
|
||||
<Playground example="post" />
|
||||
|
||||
Every effect is optional:
|
||||
|
||||
```ts
|
||||
await ssao.setEnabled(false);
|
||||
await grade.update({ toneMap: "reinhard" });
|
||||
await fxaa.dispose();
|
||||
```
|
||||
|
||||
Also available:
|
||||
|
||||
```ts
|
||||
const outline = new Silhouette(scene, { amount: 1 });
|
||||
const edgeImage = new Edges(scene, { amount: 2, enabled: false });
|
||||
await edgeImage.setEnabled(true);
|
||||
```
|
||||
|
||||
The final present pass tone-maps HDR to the canvas. `ColorGrading` supports `aces`, `reinhard`, and `linear` tone maps.
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
# Scene and shared data
|
||||
|
||||
Think of every handle as an array index, not an object mirrored into core. `Node.position`, `Node.rotor`, and `Node.scale` are component views into separate flat SOA arrays.
|
||||
|
||||
## Direct transform movement
|
||||
|
||||
```ts
|
||||
import { Node } from "@yawn/handles";
|
||||
|
||||
const pivot = new Node(scene, { position: [0, 1, 0] });
|
||||
await pivot.ready;
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
pivot.position.x += event.movementX * 0.002;
|
||||
pivot.position.y -= event.movementY * 0.002;
|
||||
});
|
||||
```
|
||||
|
||||
The pointer handler sends no messages. Each component property reads or writes its lane in the arena shared with the render worker. Replace complete transforms with `setPosition([x, y, z])`, `setRotor([x, y, z, w])`, and `setScale([x, y, z])`. `translate(...)`, `rotate(...)`, and `rotateX/Y/Z(...)` are convenience methods over those same SAB lanes. The camera helpers use this same pattern; see [Cameras and controls](/guide/cameras).
|
||||
|
||||
<Playground example="sab" />
|
||||
|
||||
## Add an application-specific row
|
||||
|
||||
```ts
|
||||
const particles = await scene.ensureRows("particleVelocity", 10_000, 16, "f32");
|
||||
particles.row(42).set([1, 0, 0, 0]);
|
||||
```
|
||||
|
||||
Rows are 16-byte-stride-aligned and arena allocations are 64-byte aligned. Formats are `f32`, `u32`, or `i32`.
|
||||
|
||||
## Timing and render signals
|
||||
|
||||
Core always creates `signals` as:
|
||||
|
||||
```text
|
||||
[deltaTime, frameCount, elapsedTime, targetFps, skipRender, sabDirty, bundleDirty, 0]
|
||||
```
|
||||
|
||||
```ts
|
||||
const signals = scene.array("signals").row(0);
|
||||
signals[4] = 1; // keep timing, skip GPU work
|
||||
signals[4] = 0; // resume and request a frame
|
||||
```
|
||||
|
||||
The handles layer sets `sabDirty` for writes made through `scene.array(...)` and its node, camera, mesh, material, and light APIs. It sets `bundleDirty` before rebuilding a graph whose recorded pipeline, bindings, geometry, or draw commands changed. Core clears `sabDirty` when it starts a frame; switching to the replacement loadout clears `bundleDirty` and requests a fresh frame.
|
||||
|
||||
Use messages for rare control changes (`setFps`, graph updates, allocation); use SAB writes for existing hot state. Code using `@yawn/core` directly must set `signals[5] = 1` after completing its own row writes.
|
||||
|
||||
<script setup>
|
||||
import Playground from "../.vitepress/Playground.vue";
|
||||
</script>
|
||||
+32
-24
@@ -2,41 +2,49 @@
|
||||
layout: home
|
||||
hero:
|
||||
name: Yawn
|
||||
text: Shared render data and a render graph.
|
||||
tagline: One Rust/WASM core, one fixed arena, no built-in scene model or shader.
|
||||
text: Render graphs over shared data.
|
||||
tagline: A small Rust/WASM core with optional conventional TypeScript handles.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Open the playground
|
||||
text: Start the tutorial
|
||||
link: /guide/getting-started
|
||||
- theme: alt
|
||||
text: Open playground
|
||||
link: /playground
|
||||
features:
|
||||
- title: Shared rows
|
||||
details: Allocate an SOA row array once by message, then mutate its SAB views directly from any thread.
|
||||
- title: External graphs
|
||||
details: JSO and FXNode addons serialize DAGs to the S-expression AST consumed by the worker.
|
||||
- title: Up-front loadouts
|
||||
details: Pipelines, GPU resources, pass order, and compatible transient aliases are prepared before activation.
|
||||
- title: Hot state stays shared
|
||||
details: Transform, material, light, and camera changes are direct SharedArrayBuffer writes from any thread.
|
||||
- title: Graph-authored GPU work
|
||||
details: WGSL, pipelines, compute, HDR, and post effects live in one externally supplied DAG loadout.
|
||||
- title: Conventional when wanted
|
||||
details: The handles addon supplies Scene, Mesh, materials, lights, glTF import, and BVH picking without adding core semantics.
|
||||
---
|
||||
|
||||
## The entire boundary
|
||||
## The shortest useful scene
|
||||
|
||||
```js
|
||||
const color = await core.allocateRows({
|
||||
name: "triangle.color",
|
||||
rows: 1,
|
||||
stride: 16,
|
||||
format: "f32",
|
||||
```ts
|
||||
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(document.querySelector("canvas"), { hdr: true });
|
||||
await scene.ready;
|
||||
|
||||
const material = new PBRMaterial(scene, { baseColor: [0.2, 0.7, 1, 1] });
|
||||
await material.ready;
|
||||
const mesh = new Mesh(scene, {
|
||||
material,
|
||||
vertexData: {
|
||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.7, 0],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
|
||||
color.write(0, [0.2, 0.65, 1, 1]);
|
||||
color.row(0)[0] = 0.8; // direct SharedArrayBuffer write
|
||||
await loadGraph(core, graph); // infrequent message
|
||||
mesh.position.x = 0.25; // direct SAB mutation
|
||||
```
|
||||
|
||||
`@yawn/core` contains only the Rust/WASM render-data arena and graph compiler plus the browser worker required to execute WebGPU. Rust owns the fixed 64-byte-aligned shared arena, DAG ordering, resource culling, and transient texture planning; the worker materializes the resulting loadout. Every scene convention and every byte of WGSL comes from an addon or application.
|
||||
`Scene` installs one HDR clustered-forward loadout. Adding compute, custom shaders, textures, or post effects rebuilds that same loadout; changing values already present in shared rows does not send a message.
|
||||
|
||||
```text
|
||||
JSO / FXNode ──▶ AST ──▶ S-expression ──▶ Rust/WASM ──▶ worker ──▶ WebGPU
|
||||
any JS thread ────────────── direct SAB row writes ────────────────────┘
|
||||
handles ──▶ graph AST ──▶ S-expression ──▶ core worker ──▶ Rust/WebGPU
|
||||
any JS thread ─────────────── direct SAB row writes ────────────────┘
|
||||
```
|
||||
|
||||
The addon packages provide graph serialization, optional WGSL, glTF import directly into shared rows, and conventional camera/material/mesh handles. None of them add semantics to core.
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
# Minimal playground
|
||||
---
|
||||
layout: false
|
||||
---
|
||||
|
||||
This is the one runnable example. It allocates a single `f32` row, sends an externally authored JSO render graph through the AST codec, and changes color by writing the shared row directly on pointer movement.
|
||||
|
||||
<Playground />
|
||||
<Playground fullscreen />
|
||||
|
||||
<script setup>
|
||||
import Playground from "./.vitepress/Playground.vue";
|
||||
|
||||
Binary file not shown.
Generated
+701
-48
@@ -12,37 +12,19 @@
|
||||
"addons/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"vitepress": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"addons/default-pipelines": {
|
||||
"name": "@yawn/default-pipelines",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"addons/gltf-import": {
|
||||
"name": "@yawn/gltf-import",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"addons/mesh-handles": {
|
||||
"name": "@yawn/mesh-handles",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"addons/render-graph-ast": {
|
||||
"name": "@yawn/render-graph-ast",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"addons/render-graph-fxnode": {
|
||||
"name": "@yawn/render-graph-fxnode",
|
||||
"addons/handles": {
|
||||
"name": "@yawn/handles",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@yawn/render-graph-ast": "0.1.0"
|
||||
}
|
||||
},
|
||||
"addons/render-graph-js": {
|
||||
"name": "@yawn/render-graph-js",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@yawn/render-graph-ast": "0.1.0"
|
||||
"@yawn/core": "0.1.0"
|
||||
}
|
||||
},
|
||||
"core": {
|
||||
@@ -357,6 +339,123 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz",
|
||||
"integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.9",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz",
|
||||
"integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@docsearch/css": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz",
|
||||
@@ -680,6 +779,40 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
@@ -697,6 +830,23 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
@@ -714,6 +864,23 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
@@ -806,6 +973,52 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
|
||||
@@ -1245,28 +1458,8 @@
|
||||
"resolved": "core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yawn/default-pipelines": {
|
||||
"resolved": "addons/default-pipelines",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yawn/gltf-import": {
|
||||
"resolved": "addons/gltf-import",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yawn/mesh-handles": {
|
||||
"resolved": "addons/mesh-handles",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yawn/render-graph-ast": {
|
||||
"resolved": "addons/render-graph-ast",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yawn/render-graph-fxnode": {
|
||||
"resolved": "addons/render-graph-fxnode",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yawn/render-graph-js": {
|
||||
"resolved": "addons/render-graph-js",
|
||||
"node_modules/@yawn/handles": {
|
||||
"resolved": "addons/handles",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/algoliasearch": {
|
||||
@@ -1338,6 +1531,22 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
@@ -1365,6 +1574,13 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1416,6 +1632,422 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.9",
|
||||
"@esbuild/android-arm": "0.25.9",
|
||||
"@esbuild/android-arm64": "0.25.9",
|
||||
"@esbuild/android-x64": "0.25.9",
|
||||
"@esbuild/darwin-arm64": "0.25.9",
|
||||
"@esbuild/darwin-x64": "0.25.9",
|
||||
"@esbuild/freebsd-arm64": "0.25.9",
|
||||
"@esbuild/freebsd-x64": "0.25.9",
|
||||
"@esbuild/linux-arm": "0.25.9",
|
||||
"@esbuild/linux-arm64": "0.25.9",
|
||||
"@esbuild/linux-ia32": "0.25.9",
|
||||
"@esbuild/linux-loong64": "0.25.9",
|
||||
"@esbuild/linux-mips64el": "0.25.9",
|
||||
"@esbuild/linux-ppc64": "0.25.9",
|
||||
"@esbuild/linux-riscv64": "0.25.9",
|
||||
"@esbuild/linux-s390x": "0.25.9",
|
||||
"@esbuild/linux-x64": "0.25.9",
|
||||
"@esbuild/netbsd-arm64": "0.25.9",
|
||||
"@esbuild/netbsd-x64": "0.25.9",
|
||||
"@esbuild/openbsd-arm64": "0.25.9",
|
||||
"@esbuild/openbsd-x64": "0.25.9",
|
||||
"@esbuild/openharmony-arm64": "0.25.9",
|
||||
"@esbuild/sunos-x64": "0.25.9",
|
||||
"@esbuild/win32-arm64": "0.25.9",
|
||||
"@esbuild/win32-ia32": "0.25.9",
|
||||
"@esbuild/win32-x64": "0.25.9"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz",
|
||||
"integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz",
|
||||
"integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz",
|
||||
"integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz",
|
||||
"integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz",
|
||||
"integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz",
|
||||
"integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz",
|
||||
"integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz",
|
||||
"integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz",
|
||||
"integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
@@ -1664,6 +2296,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
@@ -1913,6 +2552,13 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
@@ -2250,6 +2896,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
+13
-2
@@ -3,11 +3,22 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["core", "addons/*"],
|
||||
"workspaces": [
|
||||
"core",
|
||||
"addons/*"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}"
|
||||
"start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||
"server": "go run ./server",
|
||||
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||
"build:coredocs": "vitepress build coredocs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"esbuild": "^0.25.9",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"vitepress": "^1.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed web
|
||||
var webFiles embed.FS
|
||||
|
||||
type application struct {
|
||||
store *playgroundStore
|
||||
packages map[string]memoryAsset
|
||||
monaco http.Handler
|
||||
assets http.Handler
|
||||
pages map[string][]byte
|
||||
}
|
||||
|
||||
func newApplication(store *playgroundStore, packages map[string]memoryAsset, monacoDirectory string) (*application, error) {
|
||||
if info, err := os.Stat(monacoDirectory); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("Monaco assets not found at %s; run npm install", monacoDirectory)
|
||||
}
|
||||
assets, err := fs.Sub(webFiles, "web/assets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages := make(map[string][]byte)
|
||||
for _, name := range []string{"home.html", "docs.html", "playground.html"} {
|
||||
content, err := webFiles.ReadFile("web/" + name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages[name] = content
|
||||
}
|
||||
return &application{
|
||||
store: store,
|
||||
packages: packages,
|
||||
monaco: http.StripPrefix("/assets/monaco/", http.FileServer(http.Dir(monacoDirectory))),
|
||||
assets: http.StripPrefix("/assets/", http.FileServer(http.FS(assets))),
|
||||
pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *application) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/pkg/", app.servePackage)
|
||||
mux.HandleFunc("/api/playgrounds", app.savePlayground)
|
||||
mux.HandleFunc("/api/playgrounds/", app.getPlayground)
|
||||
mux.Handle("/assets/monaco/", app.monaco)
|
||||
mux.Handle("/assets/", app.assets)
|
||||
mux.HandleFunc("/docs", app.servePage("docs.html"))
|
||||
mux.HandleFunc("/docs/", app.servePage("docs.html"))
|
||||
mux.HandleFunc("/playground", app.servePlayground)
|
||||
mux.HandleFunc("/playground/", app.servePlayground)
|
||||
mux.HandleFunc("/", app.serveHome)
|
||||
return app.headers(mux)
|
||||
}
|
||||
|
||||
func (app *application) headers(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
response.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
response.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
response.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
response.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
next.ServeHTTP(response, request)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) serveHome(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/" {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
app.writePage(response, request, "home.html")
|
||||
}
|
||||
|
||||
func (app *application) servePage(name string) http.HandlerFunc {
|
||||
return func(response http.ResponseWriter, request *http.Request) {
|
||||
app.writePage(response, request, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) servePlayground(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/playground" && request.URL.Path != "/playground/" {
|
||||
parts := strings.Split(strings.Trim(request.URL.Path, "/"), "/")
|
||||
if len(parts) != 3 || parts[0] != "playground" || !playgroundIDPattern.MatchString(parts[1]) {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
if revision, err := strconv.Atoi(parts[2]); err != nil || revision < 1 {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
}
|
||||
app.writePage(response, request, "playground.html")
|
||||
}
|
||||
|
||||
func (app *application) writePage(response http.ResponseWriter, request *http.Request, name string) {
|
||||
response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
response.Header().Set("Cache-Control", "no-cache")
|
||||
if request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodGet {
|
||||
response.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_, _ = response.Write(app.pages[name])
|
||||
}
|
||||
|
||||
func (app *application) servePackage(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
response.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean(request.URL.Path), "/pkg/")
|
||||
asset, exists := app.packages[name]
|
||||
if !exists || name == "." {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
response.Header().Set("Cache-Control", "no-cache")
|
||||
response.Header().Set("Content-Type", asset.contentType)
|
||||
response.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
response.Header().Set("ETag", asset.etag)
|
||||
if request.Header.Get("If-None-Match") == asset.etag {
|
||||
response.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
http.ServeContent(response, request, name, asset.modified, bytes.NewReader(asset.content))
|
||||
}
|
||||
|
||||
func (app *application) savePlayground(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost {
|
||||
response.Header().Set("Allow", "POST")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
request.Body = http.MaxBytesReader(response, request.Body, maximumCodeBytes+4096)
|
||||
var input struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
writeJSONError(response, "The save request is not valid.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := app.store.save(request.Context(), input.ID, strings.TrimSpace(input.Title), input.Code)
|
||||
if err != nil {
|
||||
writeJSONError(response, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(response, result, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (app *application) getPlayground(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet {
|
||||
response.Header().Set("Allow", "GET")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/api/playgrounds/"), "/")
|
||||
if len(parts) != 2 {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
revision, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
result, err := app.store.get(request.Context(), parts[0], revision)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSONError(response, "Playground not found.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSONError(response, "Could not load the playground.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
writeJSON(response, result, http.StatusOK)
|
||||
}
|
||||
|
||||
func writeJSON(response http.ResponseWriter, value any, status int) {
|
||||
response.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
response.WriteHeader(status)
|
||||
_ = json.NewEncoder(response).Encode(value)
|
||||
}
|
||||
|
||||
func writeJSONError(response http.ResponseWriter, message string, status int) {
|
||||
writeJSON(response, map[string]string{"error": message}, status)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testApplication(t *testing.T) *application {
|
||||
t.Helper()
|
||||
store, err := openPlaygroundStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
monaco := filepath.Join(t.TempDir(), "monaco")
|
||||
if err := os.MkdirAll(monaco, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := newApplication(store, map[string]memoryAsset{
|
||||
"core.js": {
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
content: []byte("export const ready = true"),
|
||||
etag: `"core"`,
|
||||
modified: time.Unix(0, 0),
|
||||
},
|
||||
}, monaco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func TestPackageIsServedFromMemory(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/pkg/core.js", nil)
|
||||
response := httptest.NewRecorder()
|
||||
testApplication(t).routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if response.Body.String() != "export const ready = true" {
|
||||
t.Fatalf("body = %q", response.Body.String())
|
||||
}
|
||||
if response.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
|
||||
t.Fatal("cross-origin isolation header is missing")
|
||||
}
|
||||
if response.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Fatal("package CORS header is missing")
|
||||
}
|
||||
if response.Header().Get("Cross-Origin-Resource-Policy") != "cross-origin" {
|
||||
t.Fatal("package resource policy is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
|
||||
app := testApplication(t)
|
||||
handler := app.routes()
|
||||
requestBody, _ := json.Marshal(map[string]string{
|
||||
"title": "First scene",
|
||||
"code": "const scene = true;",
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("save status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var saved playground
|
||||
if err := json.NewDecoder(response.Body).Decode(&saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Revision != 1 || !playgroundIDPattern.MatchString(saved.ID) {
|
||||
t.Fatalf("saved = %#v", saved)
|
||||
}
|
||||
requestBody, _ = json.Marshal(map[string]string{
|
||||
"id": saved.ID,
|
||||
"title": "Second scene",
|
||||
"code": "const scene = false;",
|
||||
})
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody))
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("second save status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var second playground
|
||||
if err := json.NewDecoder(response.Body).Decode(&second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != saved.ID || second.Revision != 2 {
|
||||
t.Fatalf("second = %#v", second)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/1", nil)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("load status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var loaded playground
|
||||
if err := json.NewDecoder(response.Body).Decode(&loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Code != "const scene = true;" || loaded.Title != "First scene" {
|
||||
t.Fatalf("loaded = %#v", loaded)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/2", nil)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("second load status = %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Code != "const scene = false;" || loaded.Title != "Second scene" {
|
||||
t.Fatalf("second loaded = %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedPlaygroundPageRoute(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/playground/abcdef12/1", nil)
|
||||
response := httptest.NewRecorder()
|
||||
testApplication(t).routes().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "text/html; charset=utf-8" {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const [root, output] = process.argv.slice(2);
|
||||
if (!root || !output) throw new Error("usage: node build.mjs <repository> <output>");
|
||||
|
||||
const browser = {
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
legalComments: "none",
|
||||
minify: true,
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
};
|
||||
|
||||
const workerFiles = new Map([
|
||||
[path.join(root, "core", "index.js"), "./worker.js"],
|
||||
[path.join(root, "addons", "handles", "src", "importers", "gltf.ts"), "./importer-worker.js"],
|
||||
[path.join(root, "addons", "handles", "src", "bvh", "picking.ts"), "./bvh-worker.js"],
|
||||
]);
|
||||
|
||||
const workerURLs = {
|
||||
name: "yawn-worker-urls",
|
||||
setup(builder) {
|
||||
builder.onLoad({ filter: /\.(js|ts)$/ }, async ({ path: source }) => {
|
||||
const worker = workerFiles.get(path.normalize(source));
|
||||
if (!worker) return;
|
||||
let contents = await fs.readFile(source, "utf8");
|
||||
const transformed = contents.replace(
|
||||
/new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/,
|
||||
`createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`,
|
||||
);
|
||||
if (transformed === contents) throw new Error(`worker constructor not found in ${source}`);
|
||||
contents = `
|
||||
function createYawnWorker(url, options) {
|
||||
if (url.origin === globalThis.location.origin) return new Worker(url, options);
|
||||
const source = "import " + JSON.stringify(url.href) + ";";
|
||||
const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
||||
const worker = new Worker(bootstrap, options);
|
||||
URL.revokeObjectURL(bootstrap);
|
||||
return worker;
|
||||
}
|
||||
${transformed}`;
|
||||
return { contents, loader: path.extname(source) === ".ts" ? "ts" : "js" };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
build({
|
||||
...browser,
|
||||
entryPoints: [path.join(root, "core", "index.js")],
|
||||
outfile: path.join(output, "core.js"),
|
||||
plugins: [workerURLs],
|
||||
}),
|
||||
build({
|
||||
...browser,
|
||||
alias: { "@yawn/core": path.join(root, "core", "index.js") },
|
||||
entryPoints: [path.join(root, "addons", "handles", "src", "index.ts")],
|
||||
outfile: path.join(output, "handles.js"),
|
||||
plugins: [workerURLs],
|
||||
}),
|
||||
build({
|
||||
bundle: false,
|
||||
entryPoints: [path.join(root, "core", "worker.js")],
|
||||
format: "esm",
|
||||
legalComments: "none",
|
||||
minify: true,
|
||||
outfile: path.join(output, "worker.js"),
|
||||
target: "es2022",
|
||||
}),
|
||||
build({
|
||||
...browser,
|
||||
entryPoints: [path.join(root, "addons", "handles", "src", "importers", "worker.ts")],
|
||||
outfile: path.join(output, "importer-worker.js"),
|
||||
}),
|
||||
build({
|
||||
...browser,
|
||||
entryPoints: [path.join(root, "addons", "handles", "src", "bvh", "worker.ts")],
|
||||
outfile: path.join(output, "bvh-worker.js"),
|
||||
}),
|
||||
]);
|
||||
@@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
maximumCodeBytes = 200 * 1024
|
||||
maximumTitleRunes = 80
|
||||
)
|
||||
|
||||
var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`)
|
||||
|
||||
type playground struct {
|
||||
ID string `json:"id"`
|
||||
Revision int `json:"revision"`
|
||||
Title string `json:"title"`
|
||||
Code string `json:"code"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type playgroundStore struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func openPlaygroundStore(path string) (*playgroundStore, error) {
|
||||
if path != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
database, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
database.SetMaxOpenConns(1)
|
||||
store := &playgroundStore{DB: database}
|
||||
if err := store.initialize(); err != nil {
|
||||
database.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store *playgroundStore) initialize() error {
|
||||
_, err := store.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS playgrounds (
|
||||
id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||
title TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id, revision)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS playgrounds_created_at
|
||||
ON playgrounds (created_at DESC);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *playgroundStore) save(ctx context.Context, id, title, code string) (playground, error) {
|
||||
if id != "" && !playgroundIDPattern.MatchString(id) {
|
||||
return playground{}, errors.New("invalid playground id")
|
||||
}
|
||||
if title == "" || utf8.RuneCountInString(title) > maximumTitleRunes {
|
||||
return playground{}, fmt.Errorf("title must contain 1 to %d characters", maximumTitleRunes)
|
||||
}
|
||||
if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) {
|
||||
return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes)
|
||||
}
|
||||
if id == "" {
|
||||
var err error
|
||||
id, err = randomPlaygroundID()
|
||||
if err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
}
|
||||
|
||||
transaction, err := store.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
var revision int
|
||||
if err := transaction.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT COALESCE(MAX(revision), 0) + 1 FROM playgrounds WHERE id = ?`,
|
||||
id,
|
||||
).Scan(&revision); err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
createdAt := time.Now().UTC().Truncate(time.Millisecond)
|
||||
if _, err := transaction.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO playgrounds (id, revision, title, code, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
id,
|
||||
revision,
|
||||
title,
|
||||
code,
|
||||
createdAt.Format(time.RFC3339Nano),
|
||||
); err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
return playground{ID: id, Revision: revision, Title: title, Code: code, CreatedAt: createdAt}, nil
|
||||
}
|
||||
|
||||
func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) {
|
||||
if !playgroundIDPattern.MatchString(id) || revision < 1 {
|
||||
return playground{}, sql.ErrNoRows
|
||||
}
|
||||
var result playground
|
||||
var createdAt string
|
||||
err := store.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, revision, title, code, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
|
||||
id,
|
||||
revision,
|
||||
).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &createdAt)
|
||||
if err != nil {
|
||||
return playground{}, err
|
||||
}
|
||||
result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func randomPlaygroundID() (string, error) {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"
|
||||
result := make([]byte, 10)
|
||||
for index := range result {
|
||||
value, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result[index] = alphabet[value.Int64()]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.heaust.org/heaust/yawn/server
|
||||
|
||||
go 1.19
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.32
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
root, err := repositoryRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Print("building core and handles packages")
|
||||
packages, err := buildPackages(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build packages: %w", err)
|
||||
}
|
||||
log.Printf("loaded %d package assets into memory", len(packages))
|
||||
|
||||
databasePath := os.Getenv("YAWN_DATABASE")
|
||||
if databasePath == "" {
|
||||
databasePath = filepath.Join(root, "server", "data", "yawn.db")
|
||||
}
|
||||
store, err := openPlaygroundStore(databasePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open playground database: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
app, err := newApplication(
|
||||
store,
|
||||
packages,
|
||||
filepath.Join(root, "node_modules", "monaco-editor", "min"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: app.routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
stopped := make(chan os.Signal, 1)
|
||||
signal.Notify(stopped, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-stopped
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Yawn server listening on http://0.0.0.0:%s", port)
|
||||
err = server.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func repositoryRoot() (string, error) {
|
||||
directory, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for {
|
||||
if fileExists(filepath.Join(directory, "Cargo.toml")) &&
|
||||
fileExists(filepath.Join(directory, "core", "Cargo.toml")) &&
|
||||
fileExists(filepath.Join(directory, "addons", "handles", "package.json")) {
|
||||
return directory, nil
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
return "", errors.New("could not find the Yawn repository root")
|
||||
}
|
||||
directory = parent
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user