Strip core to render data and render graphs
Move glTF, picking, camera controls, and conventional handles into addons. Keep camera and material mutations in SIMD-aligned shared SOA rows and synchronize material updates directly into GPU buffers. Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
yawn-dev:
|
yawn-examples:
|
||||||
command: npm run dev
|
command: npm run examples
|
||||||
portal:
|
portal:
|
||||||
title: Yawn
|
title: Yawn examples
|
||||||
description: WebGPU level editor development server with hot reload.
|
description: Example index and WebGPU render graph studio with hot reload.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build, Lint, and Test Commands
|
# Build, Lint, and Test Commands
|
||||||
- `npm run dev`: Start Vite dev server with hot reload for WASM bundle
|
- `npm run examples`: Start the examples index with Vite and WASM hot reload
|
||||||
- `npm run build`: Build optimized WASM and JS in `dist/` for development
|
- `npm run build`: Build optimized WASM and JS in `dist/` for development
|
||||||
- `npm run build-release`: Build optimized WASM and JS for production
|
- `npm run build-release`: Build optimized WASM and JS for production
|
||||||
- `cargo check`: Validate Rust sources quickly before full builds
|
- `cargo check`: Validate Rust sources quickly before full builds
|
||||||
|
|||||||
Generated
-227
@@ -17,15 +17,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "approx"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@@ -47,12 +38,6 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -115,12 +100,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "byteorder"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder-lite"
|
name = "byteorder-lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -139,16 +118,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cgmath"
|
|
||||||
version = "0.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317"
|
|
||||||
dependencies = [
|
|
||||||
"approx",
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -170,16 +139,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "console_log"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -289,95 +248,6 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
|
||||||
dependencies = [
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-executor",
|
|
||||||
"futures-io",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
|
||||||
"futures-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-channel"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"futures-sink",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-core"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-executor"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"futures-task",
|
|
||||||
"futures-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-io"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-macro"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-sink"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-task"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-util"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
|
||||||
dependencies = [
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-io",
|
|
||||||
"futures-macro",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
|
||||||
"memchr",
|
|
||||||
"pin-project-lite",
|
|
||||||
"pin-utils",
|
|
||||||
"slab",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gl_generator"
|
name = "gl_generator"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -401,45 +271,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gltf"
|
|
||||||
version = "1.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"byteorder",
|
|
||||||
"gltf-json",
|
|
||||||
"image",
|
|
||||||
"lazy_static",
|
|
||||||
"serde_json",
|
|
||||||
"urlencoding",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gltf-derive"
|
|
||||||
version = "1.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
|
|
||||||
dependencies = [
|
|
||||||
"inflections",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gltf-json"
|
|
||||||
version = "1.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
|
|
||||||
dependencies = [
|
|
||||||
"gltf-derive",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glutin_wgl_sys"
|
name = "glutin_wgl_sys"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -550,12 +381,6 @@ dependencies = [
|
|||||||
"hashbrown",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "inflections"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -595,29 +420,6 @@ version = "3.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lazy_static"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "level-editor"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"bytemuck",
|
|
||||||
"console_error_panic_hook",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"renderer",
|
|
||||||
"ultraviolet",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"wasm-logger",
|
|
||||||
"web-sys",
|
|
||||||
"wgpu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.174"
|
version = "0.2.174"
|
||||||
@@ -800,18 +602,6 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project-lite"
|
|
||||||
version = "0.2.16"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-utils"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -908,15 +698,10 @@ name = "renderer"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cgmath",
|
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"console_log",
|
|
||||||
"futures",
|
|
||||||
"gltf",
|
|
||||||
"image",
|
"image",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"raw-window-handle",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.15",
|
"thiserror 2.0.15",
|
||||||
@@ -999,12 +784,6 @@ version = "0.3.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "slab"
|
|
||||||
version = "0.4.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slotmap"
|
name = "slotmap"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1116,12 +895,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urlencoding"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|||||||
+6
-28
@@ -1,47 +1,25 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["renderer", "level-editor"]
|
members = ["renderer"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] }
|
wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] }
|
||||||
wasm-bindgen-futures = "0.4.50"
|
wasm-bindgen-futures = "0.4.50"
|
||||||
console_error_panic_hook = "0.1.7"
|
console_error_panic_hook = "0.1.7"
|
||||||
console_log = "1.0.0"
|
|
||||||
log = "0.4.27"
|
log = "0.4.27"
|
||||||
wasm-logger = "0.2.0"
|
wasm-logger = "0.2.0"
|
||||||
web-sys = { version = "0.3.77", features = [
|
web-sys = { version = "0.3.77", features = [
|
||||||
"Window",
|
|
||||||
"Document",
|
|
||||||
"Element",
|
|
||||||
"HtmlCanvasElement",
|
|
||||||
"HtmlInputElement",
|
|
||||||
"File",
|
|
||||||
"FileList",
|
|
||||||
"OffscreenCanvas",
|
"OffscreenCanvas",
|
||||||
"MouseEvent",
|
|
||||||
"PointerEvent",
|
|
||||||
"WheelEvent",
|
|
||||||
"Worker",
|
|
||||||
"DedicatedWorkerGlobalScope",
|
"DedicatedWorkerGlobalScope",
|
||||||
"Event",
|
"MessageEvent"
|
||||||
"MessageEvent",
|
|
||||||
"Blob",
|
|
||||||
"BlobPropertyBag",
|
|
||||||
"Url",
|
|
||||||
"Request",
|
|
||||||
"RequestInit",
|
|
||||||
"RequestMode",
|
|
||||||
"Response",
|
|
||||||
"Headers",
|
|
||||||
"AddEventListenerOptions"
|
|
||||||
]}
|
]}
|
||||||
js-sys = "0.3.77"
|
js-sys = "0.3.77"
|
||||||
bytemuck = { version = "1.23.1", features = ["derive"] }
|
bytemuck = { version = "1.23.1", features = ["derive"] }
|
||||||
cgmath = "0.18"
|
|
||||||
raw-window-handle = "0.6.2"
|
|
||||||
wgpu = "26.0.1"
|
wgpu = "26.0.1"
|
||||||
thiserror = "2.0.15"
|
thiserror = "2.0.15"
|
||||||
ultraviolet = "0.10.0"
|
ultraviolet = "0.10.0"
|
||||||
futures = "0.3"
|
|
||||||
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual", "KHR_materials_ior"] }
|
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ JavaScript objects ──┘ │
|
|||||||
|
|
||||||
Any browser thread ── infrequent commands ──────────────────> worker
|
Any browser thread ── infrequent commands ──────────────────> worker
|
||||||
Any browser thread ── atomic SOA writes ────────────────────> shared WASM memory
|
Any browser thread ── atomic SOA writes ────────────────────> shared WASM memory
|
||||||
glTF import worker ── fetch URL ──> fixed shared SOA upload ─┘
|
glTF import worker ── parse URL ──> generic render-data packet ─> fixed shared SOA ─┘
|
||||||
```
|
```
|
||||||
|
|
||||||
The canonical AST is the only public render-graph wire format. Nodes are named
|
The canonical AST is the only public render-graph wire format. Nodes are named
|
||||||
@@ -29,17 +29,19 @@ pipelines before activating a graph.
|
|||||||
Authored render shaders use Yawn's fixed scene ABI. Render and compute declarations
|
Authored render shaders use Yawn's fixed scene ABI. Render and compute declarations
|
||||||
carry source, entry points, and dispatch/state metadata and are prepared with the
|
carry source, entry points, and dispatch/state metadata and are prepared with the
|
||||||
graph loadout. Core contains no built-in shader source or pipeline declarations.
|
graph loadout. Core contains no built-in shader source or pipeline declarations.
|
||||||
|
Its public responsibility stops at shared render data and render-graph compilation,
|
||||||
|
loadouts, lifecycle, and transient resource management; conveniences live outside it.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
- `packages/yawn-core` (`@yawn/core`) — the worker command transport, serialized
|
- `packages/yawn-core` (`@yawn/core`) — render-data shared arrays and render-graph
|
||||||
graph lifecycle, and shared SOA views; it returns `[slot, generation]` handles.
|
lifecycle transport; it returns `[slot, generation]` render-data handles.
|
||||||
- `addons/render-graph-ast` — canonical immutable DAG AST and S-expression serializer.
|
- `addons/render-graph-ast` — canonical immutable DAG AST and S-expression serializer.
|
||||||
- `addons/render-graph-js` — plain-object/fluent graph APIs that serialize and load ASTs.
|
- `addons/render-graph-js` — plain-object/fluent graph APIs that serialize and load ASTs.
|
||||||
- `addons/render-graph-fxnode` — FXNode snapshot exporter and diagnostic mapping.
|
- `addons/render-graph-fxnode` — FXNode snapshot exporter and diagnostic mapping.
|
||||||
- `addons/default-pipelines` — optional scene/frame shader and compute declarations.
|
- `addons/default-pipelines` — optional scene/frame shader and compute declarations.
|
||||||
- `addons/gltf-import` — URL-fetching worker that writes GLB bytes directly to a fixed SOA.
|
- `addons/gltf-import` — glTF worker that writes format-neutral render-data packets directly to a fixed SOA.
|
||||||
- `addons/mesh-handles` — conventional `Mesh`/`Instance` objects and optional BVH picking.
|
- `addons/mesh-handles` — conventional mesh, instance, camera, and material objects plus optional BVH picking.
|
||||||
|
|
||||||
The integration example in `examples/render-graph-studio` consumes every package
|
The integration example in `examples/render-graph-studio` consumes every package
|
||||||
through its public API; no example source or shader lives in core.
|
through its public API; no example source or shader lives in core.
|
||||||
@@ -80,7 +82,8 @@ await graph.load(core);
|
|||||||
`@yawn/core` exposes 64-byte-aligned shared SOA columns. Every stride is a multiple
|
`@yawn/core` exposes 64-byte-aligned shared SOA columns. Every stride is a multiple
|
||||||
of 16 bytes and scalar lanes are atomic `u32`, `i32`, or IEEE-754 `f32` bits. The
|
of 16 bytes and scalar lanes are atomic `u32`, `i32`, or IEEE-754 `f32` bits. The
|
||||||
built-in instance transform/type columns are generation-guarded so a stale handle
|
built-in instance transform/type columns are generation-guarded so a stale handle
|
||||||
cannot mutate a reused slot.
|
cannot mutate a reused slot. The built-in `camera.state` column is one 64-byte,
|
||||||
|
16-lane `f32` row containing eye, target, up, and projection parameters.
|
||||||
|
|
||||||
Allocate application columns infrequently through the worker:
|
Allocate application columns infrequently through the worker:
|
||||||
|
|
||||||
@@ -95,16 +98,32 @@ const velocity = await core.allocateArray({
|
|||||||
velocity.write(instanceSlot, [1, 0, 0, 0]);
|
velocity.write(instanceSlot, [1, 0, 0, 0]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Camera state has no dedicated core API. Read and write it through the same render-data
|
||||||
|
SOA interface as every other hot value; these mutations do not enqueue worker messages:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const camera = core.array("camera.state");
|
||||||
|
const state = camera.read(0);
|
||||||
|
state[0] = nextEye[0];
|
||||||
|
state[1] = nextEye[1];
|
||||||
|
state[2] = nextEye[2];
|
||||||
|
camera.write(0, state);
|
||||||
|
```
|
||||||
|
|
||||||
Import a GLB without transferring its bytes through renderer messages:
|
Import a GLB without transferring its bytes through renderer messages:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
import { GltfImporter } from "@yawn/gltf-import";
|
||||||
import { MeshHandles } from "@yawn/mesh-handles";
|
import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
|
||||||
|
|
||||||
const importer = new GltfImporter(core);
|
const importer = new GltfImporter(core);
|
||||||
const handles = new MeshHandles(core);
|
const imported = await importer.load(gltfUrl);
|
||||||
const meshes = handles.fromImportedScene(await importer.load(gltfUrl));
|
const meshes = new MeshHandles(core).fromImportedScene(imported);
|
||||||
|
const materials = new MaterialHandles(core).fromImportedScene(imported);
|
||||||
|
const camera = new CameraHandle(core);
|
||||||
meshes[0].defaultInstance.setTransform(nextTransform); // direct shared-SOA write
|
meshes[0].defaultInstance.setTransform(nextTransform); // direct shared-SOA write
|
||||||
|
materials[0].roughness = 0.35; // direct shared-SOA write
|
||||||
|
camera.position = [4, 3, 6]; // direct shared-SOA write
|
||||||
```
|
```
|
||||||
|
|
||||||
The renderer grows mesh/instance-domain columns with render-data capacity and
|
The renderer grows mesh/instance-domain columns with render-data capacity and
|
||||||
@@ -115,7 +134,7 @@ use shared memory; a GLB commit message contains only an array ID and byte count
|
|||||||
|
|
||||||
`YawnCore` accepts a transport bridge whose worker endpoint can be a `Worker` or a
|
`YawnCore` accepts a transport bridge whose worker endpoint can be a `Worker` or a
|
||||||
started `MessagePort`, so the same API can run on the browser main thread or another
|
started `MessagePort`, so the same API can run on the browser main thread or another
|
||||||
worker. Optional picking is installed with the mesh addon's `createPickingWorker`.
|
worker. Optional snapshot/BVH picking is owned entirely by the mesh-handles addon.
|
||||||
|
|
||||||
Cross-origin isolation is required (`COOP: same-origin`, `COEP: require-corp`). The
|
Cross-origin isolation is required (`COOP: same-origin`, `COEP: require-corp`). The
|
||||||
Vite development and preview servers already set both headers.
|
Vite development and preview servers already set both headers.
|
||||||
@@ -123,7 +142,7 @@ Vite development and preview servers already set both headers.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
npm run examples
|
||||||
npm run test:js
|
npm run test:js
|
||||||
cargo check --workspace
|
cargo check --workspace
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const gltfShader = /* wgsl */ `
|
export const gltfShader = /* wgsl */ `
|
||||||
struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
|
struct UniformData { resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
|
||||||
struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
|
struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
|
||||||
@group(0) @binding(0) var<uniform> uni: UniformData;
|
@group(0) @binding(0) var<uniform> uni: UniformData;
|
||||||
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
|
||||||
@@ -76,8 +76,8 @@ export const noopComputeShader = /* wgsl */ `@compute @workgroup_size(1) fn main
|
|||||||
export const defaultPipelines = Object.freeze({
|
export const defaultPipelines = Object.freeze({
|
||||||
render: Object.freeze([
|
render: Object.freeze([
|
||||||
Object.freeze({ name: "ground_plane", shader: groundShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }),
|
Object.freeze({ name: "ground_plane", shader: groundShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }),
|
||||||
Object.freeze({ name: "gltf_standard", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }),
|
Object.freeze({ name: "gltf_standard", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", material: true }),
|
||||||
Object.freeze({ name: "gltf_standard_double_sided", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", doubleSided: true }),
|
Object.freeze({ name: "gltf_standard_double_sided", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", doubleSided: true, material: true }),
|
||||||
Object.freeze({ name: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }),
|
Object.freeze({ name: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }),
|
||||||
]),
|
]),
|
||||||
compute: Object.freeze([
|
compute: Object.freeze([
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@yawn/gltf-import",
|
"name": "@yawn/gltf-import",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "glTF fetch worker that uploads directly into Yawn shared SOA memory",
|
"description": "glTF worker that publishes generic render data directly into Yawn shared SOA memory",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./src/index.js"
|
"exports": "./src/index.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -6,7 +6,31 @@ export class GltfImportError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetches glTF in a dedicated worker and commits only shared-memory upload metadata. */
|
function frameCamera(core, bounds, framing) {
|
||||||
|
if (!bounds || framing === false) return;
|
||||||
|
if (framing !== undefined && framing !== "exterior" && framing !== "interior")
|
||||||
|
throw new TypeError("framing must be exterior, interior, or false");
|
||||||
|
const min = bounds.min, max = bounds.max;
|
||||||
|
if (!Array.isArray(min) || !Array.isArray(max) || min.length !== 3 || max.length !== 3) return;
|
||||||
|
const center = min.map((value, axis) => (value + max[axis]) * 0.5);
|
||||||
|
const extent = max.map((value, axis) => value - min[axis]);
|
||||||
|
const radius = Math.max(1, Math.hypot(...extent) * 0.5);
|
||||||
|
const camera = core.array("camera.state");
|
||||||
|
const state = camera.read(0);
|
||||||
|
const interior = framing === "interior";
|
||||||
|
const eye = interior
|
||||||
|
? [center[0], center[1] + radius * 0.05, center[2]]
|
||||||
|
: [center[0] + radius * 1.8, center[1] + radius * 1.4, center[2] + radius * 1.8];
|
||||||
|
const target = interior ? [center[0] + radius, center[1], center[2]] : center;
|
||||||
|
state.splice(0, 3, ...eye);
|
||||||
|
state.splice(4, 3, ...target);
|
||||||
|
state.splice(8, 3, 0, 1, 0);
|
||||||
|
state[14] = Math.max(radius * 0.001, 0.1);
|
||||||
|
state[15] = Math.max(radius * 6, 1.1);
|
||||||
|
camera.write(0, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses glTF in a dedicated worker and publishes a generic render-data packet through shared memory. */
|
||||||
export class GltfImporter {
|
export class GltfImporter {
|
||||||
#core;
|
#core;
|
||||||
#worker;
|
#worker;
|
||||||
@@ -16,7 +40,8 @@ export class GltfImporter {
|
|||||||
#disposed = false;
|
#disposed = false;
|
||||||
|
|
||||||
constructor(core, { workerFactory } = {}) {
|
constructor(core, { workerFactory } = {}) {
|
||||||
if (!core?.allocateArray || !core?.commitGlbUpload) throw new TypeError("core must implement the Yawn shared upload protocol");
|
if (!core?.allocateArray || !core?.commitRenderDataUpload)
|
||||||
|
throw new TypeError("core must implement the Yawn shared render-data protocol");
|
||||||
this.#core = core;
|
this.#core = core;
|
||||||
this.#worker = workerFactory
|
this.#worker = workerFactory
|
||||||
? workerFactory()
|
? workerFactory()
|
||||||
@@ -55,7 +80,7 @@ export class GltfImporter {
|
|||||||
if (message.type === "allocate") {
|
if (message.type === "allocate") {
|
||||||
const length = Math.ceil(message.byteLength / 16);
|
const length = Math.ceil(message.byteLength / 16);
|
||||||
pending.array = await this.#core.allocateArray({
|
pending.array = await this.#core.allocateArray({
|
||||||
name: "upload.gltf",
|
name: "upload.renderData",
|
||||||
domain: "fixed",
|
domain: "fixed",
|
||||||
scalar: "u32",
|
scalar: "u32",
|
||||||
lanes: 4,
|
lanes: 4,
|
||||||
@@ -68,11 +93,11 @@ export class GltfImporter {
|
|||||||
...pending.array.share(),
|
...pending.array.share(),
|
||||||
});
|
});
|
||||||
} else if (message.type === "ready") {
|
} else if (message.type === "ready") {
|
||||||
const result = await this.#core.commitGlbUpload(
|
const result = await this.#core.commitRenderDataUpload(
|
||||||
pending.array,
|
pending.array,
|
||||||
message.byteLength,
|
message.byteLength,
|
||||||
pending.options,
|
|
||||||
);
|
);
|
||||||
|
frameCamera(this.#core, result.bounds, pending.options.framing);
|
||||||
this.#pending.delete(message.request);
|
this.#pending.delete(message.request);
|
||||||
pending.resolve(result);
|
pending.resolve(result);
|
||||||
} else if (message.type === "error") {
|
} else if (message.type === "error") {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { writeSharedUpload } from "./shared-upload.js";
|
import { writeSharedUpload } from "./shared-upload.js";
|
||||||
|
import { gltfToRenderDataPacket } from "./gltf.js";
|
||||||
|
|
||||||
const downloads = new Map();
|
const downloads = new Map();
|
||||||
|
|
||||||
@@ -10,16 +11,17 @@ addEventListener("message", async ({ data: message }) => {
|
|||||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
if (!bytes.byteLength) throw new Error("GLTF_EMPTY");
|
if (!bytes.byteLength) throw new Error("GLTF_EMPTY");
|
||||||
downloads.set(request, bytes);
|
const packet = await gltfToRenderDataPacket(bytes, message.url);
|
||||||
postMessage({ type: "allocate", request, byteLength: bytes.byteLength });
|
downloads.set(request, packet);
|
||||||
|
postMessage({ type: "allocate", request, byteLength: packet.byteLength });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message?.type === "storage") {
|
if (message?.type === "storage") {
|
||||||
const bytes = downloads.get(request);
|
const packet = downloads.get(request);
|
||||||
if (!bytes) throw new Error("GLTF_REQUEST_UNKNOWN");
|
if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN");
|
||||||
writeSharedUpload(message.buffer, message.descriptor, bytes);
|
writeSharedUpload(message.buffer, message.descriptor, packet);
|
||||||
downloads.delete(request);
|
downloads.delete(request);
|
||||||
postMessage({ type: "ready", request, byteLength: bytes.byteLength });
|
postMessage({ type: "ready", request, byteLength: packet.byteLength });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
downloads.delete(request);
|
downloads.delete(request);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@yawn/mesh-handles",
|
"name": "@yawn/mesh-handles",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Conventional mesh handles over the Yawn worker and shared-SOA protocol",
|
"description": "Conventional mesh, instance, camera, and material handles over Yawn render data",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SnapshotReader } from "@yawn/core/snapshot";
|
import { SnapshotReader } from "./snapshot.js";
|
||||||
import { DerivedBvh } from "./bvh-core.js";
|
import { DerivedBvh } from "./bvh-core.js";
|
||||||
|
|
||||||
let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0;
|
let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
const TOKEN = Symbol("yawn mesh handle addon");
|
import { RendererError } from "@yawn/core";
|
||||||
|
import { SnapshotReader } from "./snapshot.js";
|
||||||
|
|
||||||
/** Creates the optional snapshot/BVH worker used by core's picking protocol. */
|
const TOKEN = Symbol("yawn mesh handle addon");
|
||||||
export const createPickingWorker = () => new Worker(
|
const SNAPSHOT_EVENT = "yawn-render-data-snapshot";
|
||||||
|
const PUBLISHED_EVENT = "yawn-render-data-snapshot-published";
|
||||||
|
const createPickingWorker = () => new Worker(
|
||||||
new URL("./bvh-worker.js", import.meta.url),
|
new URL("./bvh-worker.js", import.meta.url),
|
||||||
{ type: "module", name: "yawn-spatial-query" },
|
{ type: "module", name: "yawn-spatial-query" },
|
||||||
);
|
);
|
||||||
@@ -9,9 +12,17 @@ export const createPickingWorker = () => new Worker(
|
|||||||
/** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */
|
/** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */
|
||||||
export class MeshHandles {
|
export class MeshHandles {
|
||||||
#core;
|
#core;
|
||||||
|
#factory; #worker; #reader; #snapshot; #epoch = 0; #next = 1; #picks = new Map(); #disposed = false;
|
||||||
|
#onSnapshot; #onPublished;
|
||||||
|
|
||||||
constructor(core) {
|
constructor(core, { pickingWorkerFactory = createPickingWorker } = {}) {
|
||||||
this.#core = core;
|
this.#core = core;
|
||||||
|
this.#factory = pickingWorkerFactory;
|
||||||
|
this.#onSnapshot = event => this.#installSnapshot(event.detail);
|
||||||
|
this.#onPublished = event => this.#publish(event.detail?.epoch);
|
||||||
|
core.addEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
|
||||||
|
core.addEventListener?.(PUBLISHED_EVENT, this.#onPublished);
|
||||||
|
if (core.renderDataSnapshot) this.#installSnapshot(core.renderDataSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
fromImportedScene(result) {
|
fromImportedScene(result) {
|
||||||
@@ -20,7 +31,7 @@ export class MeshHandles {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async pickRay(origin, direction, options) {
|
async pickRay(origin, direction, options) {
|
||||||
const result = await this.#core.pickRay(origin, direction, options);
|
const result = await this.#pickRay(origin, direction, options);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
hits: result.hits.map((hit) => ({
|
hits: result.hits.map((hit) => ({
|
||||||
@@ -29,6 +40,124 @@ export class MeshHandles {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#installSnapshot(snapshot) {
|
||||||
|
try {
|
||||||
|
if (snapshot?.controlVersion !== 1 || snapshot?.schemaVersion !== 2) throw new Error("version");
|
||||||
|
this.#snapshot = snapshot;
|
||||||
|
this.#reader = new SnapshotReader(snapshot.memory, snapshot.controlPtr);
|
||||||
|
this.#epoch = this.#reader.latest().epoch;
|
||||||
|
this.#worker?.postMessage({ type: "init", ...snapshot });
|
||||||
|
} catch {
|
||||||
|
this.#disable("PICK_PROTOCOL_MISMATCH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#publish(epoch) {
|
||||||
|
if (this.#disposed || !this.#reader) return;
|
||||||
|
try {
|
||||||
|
this.#epoch = this.#reader.latest().epoch;
|
||||||
|
this.#worker?.postMessage({ type: "update", epoch: this.#epoch || (epoch >>> 0) });
|
||||||
|
} catch {
|
||||||
|
this.#disable("PICK_PROTOCOL_MISMATCH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ensureWorker() {
|
||||||
|
if (this.#worker) return true;
|
||||||
|
if (!this.#reader || !this.#factory) return false;
|
||||||
|
try {
|
||||||
|
this.#worker = this.#factory();
|
||||||
|
this.#worker.addEventListener("message", event => this.#workerMessage(event.data));
|
||||||
|
this.#worker.addEventListener("error", () => this.#disable("PICK_WORKER_ERROR"));
|
||||||
|
this.#worker.addEventListener("messageerror", () => this.#disable("PICK_WORKER_ERROR"));
|
||||||
|
this.#worker.start?.();
|
||||||
|
this.#worker.postMessage({ type: "init", ...this.#snapshot });
|
||||||
|
if (this.#epoch) this.#worker.postMessage({ type: "update", epoch: this.#epoch });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
this.#disable("PICK_WORKER_ERROR");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#disable(code) {
|
||||||
|
const error = new RendererError(code);
|
||||||
|
for (const pending of this.#picks.values()) pending.reject(error);
|
||||||
|
this.#picks.clear();
|
||||||
|
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
||||||
|
this.#worker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workerMessage(message) {
|
||||||
|
if (message?.type === "fatal") { this.#disable(message.code || "PICK_WORKER_ERROR"); return; }
|
||||||
|
if (message?.type !== "pick") return;
|
||||||
|
const pending = this.#picks.get(message.request);
|
||||||
|
if (!pending) return;
|
||||||
|
this.#picks.delete(message.request);
|
||||||
|
let latest;
|
||||||
|
try { latest = this.#reader.latest().epoch; this.#epoch = latest; }
|
||||||
|
catch { pending.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); this.#disable("PICK_PROTOCOL_MISMATCH"); return; }
|
||||||
|
if (message.stale || pending.epoch !== message.epoch || message.epoch !== latest) {
|
||||||
|
if (!pending.retried && latest) this.#sendPick({ ...pending, retried: true }, latest);
|
||||||
|
else pending.reject(new RendererError("PICK_STALE"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.resolve({
|
||||||
|
epoch: latest,
|
||||||
|
hits: (message.hits || []).map(hit => ({
|
||||||
|
instance: [hit.slot >>> 0, hit.generation >>> 0],
|
||||||
|
distance: hit.distance,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#sendPick(pending, epoch) {
|
||||||
|
const request = this.#next++ >>> 0 || this.#next++;
|
||||||
|
pending.epoch = epoch;
|
||||||
|
this.#picks.set(request, pending);
|
||||||
|
try {
|
||||||
|
this.#worker.postMessage({
|
||||||
|
type: "pick", request, epoch,
|
||||||
|
origin: pending.origin, direction: pending.direction,
|
||||||
|
maxDistance: pending.maxDistance, maxHits: pending.maxHits,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.#picks.delete(request);
|
||||||
|
pending.reject(new RendererError("PICK_WORKER_ERROR"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pickRay(origin, direction, { maxDistance = Infinity, maxHits = 1 } = {}) {
|
||||||
|
const vector = (value, name) => {
|
||||||
|
if (!value || value.length !== 3 || [...value].some(x => typeof x !== "number" || !Number.isFinite(x)))
|
||||||
|
throw new TypeError(`${name} must contain 3 finite numbers`);
|
||||||
|
return [...value];
|
||||||
|
};
|
||||||
|
origin = vector(origin, "origin");
|
||||||
|
direction = vector(direction, "direction");
|
||||||
|
if (direction.every(x => x === 0)) throw new TypeError("direction must be nonzero");
|
||||||
|
if (typeof maxDistance !== "number" || (!(Number.isFinite(maxDistance) && maxDistance >= 0) && maxDistance !== Infinity) || !Number.isInteger(maxHits) || maxHits < 1 || maxHits > 64)
|
||||||
|
throw new TypeError("invalid pick options");
|
||||||
|
if (this.#disposed) return Promise.reject(new RendererError("DISPOSED"));
|
||||||
|
if (!this.#ensureWorker()) return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
|
||||||
|
let epoch;
|
||||||
|
try { epoch = this.#reader.latest().epoch; this.#epoch = epoch; }
|
||||||
|
catch { return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); }
|
||||||
|
if (!epoch) return Promise.reject(new RendererError("PICK_STALE"));
|
||||||
|
return new Promise((resolve, reject) => this.#sendPick({
|
||||||
|
resolve, reject, origin, direction, maxDistance, maxHits, retried: false,
|
||||||
|
}, epoch));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.#disposed) return;
|
||||||
|
this.#disposed = true;
|
||||||
|
this.#core.removeEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
|
||||||
|
this.#core.removeEventListener?.(PUBLISHED_EVENT, this.#onPublished);
|
||||||
|
try { this.#worker?.postMessage?.({ type: "dispose" }); } catch { /* best effort */ }
|
||||||
|
this.#disable("DISPOSED");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Mesh {
|
export class Mesh {
|
||||||
@@ -65,3 +194,169 @@ export class Instance {
|
|||||||
setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); }
|
setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); }
|
||||||
async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; }
|
async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireArray(core, name, { domain, scalar, lanes }) {
|
||||||
|
if (!core?.array) throw new TypeError("core must implement the Yawn shared render-data protocol");
|
||||||
|
const array = core.array(name);
|
||||||
|
if (array.domain !== domain || array.scalar !== scalar || array.lanes !== lanes)
|
||||||
|
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vector(value, length, name) {
|
||||||
|
if (!value || value.length !== length || [...value].some(item => typeof item !== "number" || !Number.isFinite(item)))
|
||||||
|
throw new TypeError(`${name} must contain ${length} finite numbers`);
|
||||||
|
return Array.from(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finite(value, name) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conventional camera properties backed by the canonical SIMD-width camera SOA row. */
|
||||||
|
export class CameraHandle {
|
||||||
|
#array;
|
||||||
|
|
||||||
|
constructor(core) {
|
||||||
|
this.#array = requireArray(core, "camera.state", { domain: "fixed", scalar: "f32", lanes: 16 });
|
||||||
|
}
|
||||||
|
|
||||||
|
get state() { return this.#array.read(0); }
|
||||||
|
set state(value) { this.#write(vector(value, 16, "state")); }
|
||||||
|
get position() { return this.state.slice(0, 3); }
|
||||||
|
set position(value) { this.update({ position: value }); }
|
||||||
|
get target() { return this.state.slice(4, 7); }
|
||||||
|
set target(value) { this.update({ target: value }); }
|
||||||
|
get up() { return this.state.slice(8, 11); }
|
||||||
|
set up(value) { this.update({ up: value }); }
|
||||||
|
get fovY() { return this.state[12]; }
|
||||||
|
set fovY(value) { this.update({ fovY: value }); }
|
||||||
|
get aspect() { return this.state[13]; }
|
||||||
|
set aspect(value) { this.update({ aspect: value }); }
|
||||||
|
get near() { return this.state[14]; }
|
||||||
|
set near(value) { this.update({ near: value }); }
|
||||||
|
get far() { return this.state[15]; }
|
||||||
|
set far(value) { this.update({ far: value }); }
|
||||||
|
|
||||||
|
update(properties = {}) {
|
||||||
|
if (!properties || typeof properties !== "object") throw new TypeError("camera properties must be an object");
|
||||||
|
const known = new Set(["position", "target", "up", "fovY", "aspect", "near", "far"]);
|
||||||
|
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown camera property '${key}'`);
|
||||||
|
const state = this.state;
|
||||||
|
if (properties.position !== undefined) state.splice(0, 3, ...vector(properties.position, 3, "position"));
|
||||||
|
if (properties.target !== undefined) state.splice(4, 3, ...vector(properties.target, 3, "target"));
|
||||||
|
if (properties.up !== undefined) state.splice(8, 3, ...vector(properties.up, 3, "up"));
|
||||||
|
if (properties.fovY !== undefined) state[12] = finite(properties.fovY, "fovY");
|
||||||
|
if (properties.aspect !== undefined) state[13] = finite(properties.aspect, "aspect");
|
||||||
|
if (properties.near !== undefined) state[14] = finite(properties.near, "near");
|
||||||
|
if (properties.far !== undefined) state[15] = finite(properties.far, "far");
|
||||||
|
this.#write(state);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
lookAt(position, target, { up = this.up } = {}) {
|
||||||
|
return this.update({ position, target, up });
|
||||||
|
}
|
||||||
|
|
||||||
|
#write(state) {
|
||||||
|
const offset = state.slice(0, 3).map((value, axis) => state[4 + axis] - value);
|
||||||
|
const up = state.slice(8, 11);
|
||||||
|
const cross = [
|
||||||
|
offset[1] * up[2] - offset[2] * up[1],
|
||||||
|
offset[2] * up[0] - offset[0] * up[2],
|
||||||
|
offset[0] * up[1] - offset[1] * up[0],
|
||||||
|
];
|
||||||
|
if (Math.hypot(...offset) < 0.1 || Math.hypot(...up) === 0 || Math.hypot(...cross) === 0)
|
||||||
|
throw new RangeError("camera position, target, and up do not define a view");
|
||||||
|
if (!(state[12] > 0 && state[12] < Math.PI) || state[13] <= 0 || state[14] <= 0 || state[15] <= state[14])
|
||||||
|
throw new RangeError("camera projection is invalid");
|
||||||
|
state[3] = state[7] = 1;
|
||||||
|
state[11] = 0;
|
||||||
|
this.#array.write(0, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOAT_WORD = new ArrayBuffer(4);
|
||||||
|
const FLOAT_VIEW = new Float32Array(FLOAT_WORD);
|
||||||
|
const WORD_VIEW = new Uint32Array(FLOAT_WORD);
|
||||||
|
function wordToFloat(word) { WORD_VIEW[0] = word; return FLOAT_VIEW[0]; }
|
||||||
|
function floatToWord(value) { FLOAT_VIEW[0] = value; return WORD_VIEW[0]; }
|
||||||
|
|
||||||
|
/** Creates scene-scoped material objects over packed material SOA rows. */
|
||||||
|
export class MaterialHandles {
|
||||||
|
#array;
|
||||||
|
|
||||||
|
constructor(core) {
|
||||||
|
this.#array = requireArray(core, "material.state", { domain: "fixed", scalar: "u32", lanes: 28 });
|
||||||
|
}
|
||||||
|
|
||||||
|
fromImportedScene(result) {
|
||||||
|
if (!result || !Array.isArray(result.materials)) throw new TypeError("invalid imported scene");
|
||||||
|
return result.materials.map(material => this.get(material?.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
if (!Number.isInteger(key) || key < 0 || key > 0xffffffff || key >= this.#array.length)
|
||||||
|
throw new RangeError("material key is outside the shared material rows");
|
||||||
|
return new MaterialHandle(TOKEN, this.#array, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MaterialHandle {
|
||||||
|
#array; #key;
|
||||||
|
|
||||||
|
constructor(token, array, key) {
|
||||||
|
if (token !== TOKEN) throw new TypeError("MaterialHandle cannot be constructed directly");
|
||||||
|
this.#array = array;
|
||||||
|
this.#key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
get key() { return this.#key; }
|
||||||
|
get baseColor() { return this.#floats(0, 4); }
|
||||||
|
set baseColor(value) { this.update({ baseColor: value }); }
|
||||||
|
get emissive() { return this.#floats(4, 3); }
|
||||||
|
set emissive(value) { this.update({ emissive: value }); }
|
||||||
|
get metallic() { return this.#float(8); }
|
||||||
|
set metallic(value) { this.update({ metallic: value }); }
|
||||||
|
get roughness() { return this.#float(9); }
|
||||||
|
set roughness(value) { this.update({ roughness: value }); }
|
||||||
|
get normalScale() { return this.#float(10); }
|
||||||
|
set normalScale(value) { this.update({ normalScale: value }); }
|
||||||
|
get occlusionStrength() { return this.#float(11); }
|
||||||
|
set occlusionStrength(value) { this.update({ occlusionStrength: value }); }
|
||||||
|
get alphaCutoff() { return this.#float(13); }
|
||||||
|
set alphaCutoff(value) { this.update({ alphaCutoff: value }); }
|
||||||
|
get ior() { return this.#float(14); }
|
||||||
|
set ior(value) { this.update({ ior: value }); }
|
||||||
|
|
||||||
|
update(properties = {}) {
|
||||||
|
if (!properties || typeof properties !== "object") throw new TypeError("material properties must be an object");
|
||||||
|
const known = new Set(["baseColor", "emissive", "metallic", "roughness", "normalScale", "occlusionStrength", "alphaCutoff", "ior"]);
|
||||||
|
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown material property '${key}'`);
|
||||||
|
const words = this.#array.read(this.#key);
|
||||||
|
const setFloat = (lane, value, name) => { words[lane] = floatToWord(finite(value, name)); };
|
||||||
|
if (properties.baseColor !== undefined)
|
||||||
|
vector(properties.baseColor, 4, "baseColor").forEach((value, lane) => setFloat(lane, value, "baseColor"));
|
||||||
|
if (properties.emissive !== undefined)
|
||||||
|
vector(properties.emissive, 3, "emissive").forEach((value, lane) => setFloat(4 + lane, value, "emissive"));
|
||||||
|
for (const [name, lane] of [["metallic", 8], ["roughness", 9], ["normalScale", 10], ["occlusionStrength", 11], ["alphaCutoff", 13]]) {
|
||||||
|
if (properties[name] !== undefined) setFloat(lane, properties[name], name);
|
||||||
|
}
|
||||||
|
if (properties.metallic !== undefined && !(properties.metallic >= 0 && properties.metallic <= 1)) throw new RangeError("metallic must be in [0, 1]");
|
||||||
|
if (properties.roughness !== undefined && !(properties.roughness >= 0 && properties.roughness <= 1)) throw new RangeError("roughness must be in [0, 1]");
|
||||||
|
if (properties.occlusionStrength !== undefined && !(properties.occlusionStrength >= 0 && properties.occlusionStrength <= 1)) throw new RangeError("occlusionStrength must be in [0, 1]");
|
||||||
|
if (properties.alphaCutoff !== undefined && !(properties.alphaCutoff >= 0 && properties.alphaCutoff <= 1)) throw new RangeError("alphaCutoff must be in [0, 1]");
|
||||||
|
if (properties.ior !== undefined) {
|
||||||
|
const ior = finite(properties.ior, "ior");
|
||||||
|
if (ior !== 0 && ior < 1) throw new RangeError("ior must be 0 or at least 1");
|
||||||
|
setFloat(14, ior, "ior");
|
||||||
|
setFloat(15, ior === 0 ? 1 : ((ior - 1) / (ior + 1)) ** 2, "ior");
|
||||||
|
}
|
||||||
|
this.#array.write(this.#key, words);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#float(lane) { return wordToFloat(this.#array.read(this.#key)[lane]); }
|
||||||
|
#floats(start, length) { return this.#array.read(this.#key).slice(start, start + length).map(wordToFloat); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Shared render-data snapshot protocol used by core picking workers. */
|
/** Shared render-data snapshot protocol consumed by the mesh-handle BVH worker. */
|
||||||
export const SNAPSHOT = Object.freeze({
|
export const SNAPSHOT = Object.freeze({
|
||||||
MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256,
|
MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256,
|
||||||
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2,
|
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2,
|
||||||
@@ -51,13 +51,15 @@ function normalizePipelines(raw = {}) {
|
|||||||
vertexEntry: pipeline.vertexEntry ?? "vs_main",
|
vertexEntry: pipeline.vertexEntry ?? "vs_main",
|
||||||
fragmentEntry: pipeline.fragmentEntry ?? "fs_main",
|
fragmentEntry: pipeline.fragmentEntry ?? "fs_main",
|
||||||
doubleSided: pipeline.doubleSided ?? false,
|
doubleSided: pipeline.doubleSided ?? false,
|
||||||
|
material: pipeline.material ?? false,
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
!identifier(result.name) ||
|
!identifier(result.name) ||
|
||||||
!identifier(result.vertexEntry) ||
|
!identifier(result.vertexEntry) ||
|
||||||
!identifier(result.fragmentEntry) ||
|
!identifier(result.fragmentEntry) ||
|
||||||
typeof result.shader !== "string" ||
|
typeof result.shader !== "string" ||
|
||||||
typeof result.doubleSided !== "boolean"
|
typeof result.doubleSided !== "boolean" ||
|
||||||
|
typeof result.material !== "boolean"
|
||||||
)
|
)
|
||||||
fail("AST_PIPELINE", "invalid render pipeline declaration");
|
fail("AST_PIPELINE", "invalid render pipeline declaration");
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
# Yawn examples
|
# Yawn examples
|
||||||
|
|
||||||
|
- `index.html` is the browser landing page for the complete example and recipes.
|
||||||
- `render-graph-studio/` is the complete browser integration with FXNode, JSO,
|
- `render-graph-studio/` is the complete browser integration with FXNode, JSO,
|
||||||
external pipelines, shared glTF import, mesh handles, and picking.
|
external pipelines, shared glTF import, mesh handles, and picking.
|
||||||
- `cookbook/` contains small copyable recipes, each focused on one public API.
|
- `cookbook/` contains small copyable recipes, each focused on one public API.
|
||||||
|
|
||||||
Start the full browser example with `npm run dev`. The cookbook modules are plain
|
Start the examples index with `npm run examples`. The cookbook modules are plain
|
||||||
ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live
|
ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live
|
||||||
renderer is required.
|
renderer is required.
|
||||||
|
|||||||
@@ -11,9 +11,3 @@ export async function compileAndSwitch(core, graph) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return to clear-only mode before releasing an active compiled loadout. */
|
|
||||||
export async function dropActiveGraph(core, compiled) {
|
|
||||||
await core.switchToImmediate();
|
|
||||||
await core.dropCompiledGraph(compiled.compiledId);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { MeshHandles } from "@yawn/mesh-handles";
|
|
||||||
|
|
||||||
/** Query the optional snapshot/BVH worker and receive wrapped instance handles. */
|
/** Query the optional snapshot/BVH worker and receive wrapped instance handles. */
|
||||||
export function pickNearest(core, origin, direction) {
|
export function pickNearest(meshHandles, origin, direction) {
|
||||||
return new MeshHandles(core).pickRay(origin, direction, {
|
return meshHandles.pickRay(origin, direction, {
|
||||||
maxDistance: 10_000,
|
maxDistance: 10_000,
|
||||||
maxHits: 1,
|
maxHits: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export async function connectFromWorker(port, options = {}) {
|
|||||||
worker: port,
|
worker: port,
|
||||||
memory: options.memory,
|
memory: options.memory,
|
||||||
ringPtr: options.ringPtr,
|
ringPtr: options.ringPtr,
|
||||||
pickingWorkerFactory: options.pickingWorkerFactory,
|
|
||||||
free: options.free,
|
free: options.free,
|
||||||
});
|
});
|
||||||
await core.ready;
|
await core.ready;
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { culling } from "../render-graph-studio/render-graph/presets.js";
|
import { culling } from "../render-graph-studio/render-graph/presets.js";
|
||||||
import { importGltf } from "./09-gltf-import-worker.js";
|
import { importGltf } from "./09-gltf-import-worker.js";
|
||||||
import { compileAndSwitch, dropActiveGraph } from "./08-compile-and-switch.js";
|
import { compileAndSwitch } from "./08-compile-and-switch.js";
|
||||||
|
|
||||||
/** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */
|
/** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */
|
||||||
export async function loadCompleteScene(core, gltfUrl) {
|
export async function loadCompleteScene(core, gltfUrl) {
|
||||||
const compiled = await compileAndSwitch(core, culling);
|
|
||||||
try {
|
|
||||||
const meshes = await importGltf(core, gltfUrl);
|
const meshes = await importGltf(core, gltfUrl);
|
||||||
return {
|
const compiled = await compileAndSwitch(core, culling);
|
||||||
compiled,
|
return { compiled, meshes };
|
||||||
meshes,
|
|
||||||
dispose: () => dropActiveGraph(core, compiled),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await dropActiveGraph(core, compiled).catch(() => {});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const MIN_DISTANCE = 0.1;
|
||||||
|
const MAX_PITCH = Math.PI / 2 - 0.01;
|
||||||
|
|
||||||
|
// The camera is ordinary render data, not a core API. This example maps browser
|
||||||
|
// controls straight onto its packed SOA row without sending input messages.
|
||||||
|
export function installCameraRenderDataControls(core, canvas) {
|
||||||
|
const camera = core.array("camera.state");
|
||||||
|
const abort = new AbortController();
|
||||||
|
const options = { signal: abort.signal };
|
||||||
|
const write = (state) => camera.write(0, state);
|
||||||
|
|
||||||
|
canvas.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
(event) => {
|
||||||
|
if (event.pointerType === "mouse" && (event.button === 1 || event.button === 2)) {
|
||||||
|
event.preventDefault();
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
canvas.addEventListener(
|
||||||
|
"pointermove",
|
||||||
|
(event) => {
|
||||||
|
if (
|
||||||
|
event.pointerType !== "mouse" ||
|
||||||
|
!canvas.hasPointerCapture(event.pointerId) ||
|
||||||
|
(event.buttons & 6) === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const state = camera.read(0);
|
||||||
|
const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]];
|
||||||
|
const distance = Math.hypot(...offset);
|
||||||
|
if ((event.buttons & 4) !== 0) {
|
||||||
|
const yaw = Math.atan2(offset[0], offset[2]) + event.movementX * 0.005;
|
||||||
|
const pitch = Math.max(
|
||||||
|
-MAX_PITCH,
|
||||||
|
Math.min(
|
||||||
|
MAX_PITCH,
|
||||||
|
Math.asin(offset[1] / distance) + event.movementY * 0.005,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const horizontal = Math.cos(pitch) * distance;
|
||||||
|
state[0] = state[4] + Math.sin(yaw) * horizontal;
|
||||||
|
state[1] = state[5] + Math.sin(pitch) * distance;
|
||||||
|
state[2] = state[6] + Math.cos(yaw) * horizontal;
|
||||||
|
} else {
|
||||||
|
const forward = [
|
||||||
|
(state[4] - state[0]) / distance,
|
||||||
|
(state[5] - state[1]) / distance,
|
||||||
|
(state[6] - state[2]) / distance,
|
||||||
|
];
|
||||||
|
const right = [
|
||||||
|
forward[1] * state[10] - forward[2] * state[9],
|
||||||
|
forward[2] * state[8] - forward[0] * state[10],
|
||||||
|
forward[0] * state[9] - forward[1] * state[8],
|
||||||
|
];
|
||||||
|
const rightLength = Math.hypot(...right);
|
||||||
|
right.forEach((value, index) => (right[index] = value / rightLength));
|
||||||
|
const up = [
|
||||||
|
right[1] * forward[2] - right[2] * forward[1],
|
||||||
|
right[2] * forward[0] - right[0] * forward[2],
|
||||||
|
right[0] * forward[1] - right[1] * forward[0],
|
||||||
|
];
|
||||||
|
const units =
|
||||||
|
(2 * distance * Math.tan(state[12] * 0.5)) /
|
||||||
|
Math.max(1, canvas.clientHeight);
|
||||||
|
const translation = right.map(
|
||||||
|
(value, index) =>
|
||||||
|
-value * event.movementX * units + up[index] * event.movementY * units,
|
||||||
|
);
|
||||||
|
for (let index = 0; index < 3; index++) {
|
||||||
|
state[index] += translation[index];
|
||||||
|
state[index + 4] += translation[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(state);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
for (const type of ["pointerup", "pointercancel"]) {
|
||||||
|
canvas.addEventListener(
|
||||||
|
type,
|
||||||
|
(event) => {
|
||||||
|
if (canvas.hasPointerCapture(event.pointerId)) {
|
||||||
|
canvas.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
canvas.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const delta =
|
||||||
|
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||||
|
? event.deltaY * 16
|
||||||
|
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||||
|
? event.deltaY * canvas.clientHeight
|
||||||
|
: event.deltaY;
|
||||||
|
if (!Number.isFinite(delta) || delta === 0) return;
|
||||||
|
const state = camera.read(0);
|
||||||
|
const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]];
|
||||||
|
const distance = Math.hypot(...offset);
|
||||||
|
const nextDistance = Math.max(
|
||||||
|
MIN_DISTANCE,
|
||||||
|
Math.min(state[15] * 0.95, distance * Math.exp(0.002 * delta)),
|
||||||
|
);
|
||||||
|
for (let index = 0; index < 3; index++) {
|
||||||
|
state[index] = state[index + 4] + offset[index] * (nextDistance / distance);
|
||||||
|
}
|
||||||
|
write(state);
|
||||||
|
},
|
||||||
|
{ ...options, passive: false },
|
||||||
|
);
|
||||||
|
canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options);
|
||||||
|
|
||||||
|
return () => abort.abort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
|
||||||
|
|
||||||
|
/** Wrap one import result in the conventional object API without hiding core. */
|
||||||
|
export function conventionalSceneHandles(core, imported) {
|
||||||
|
return {
|
||||||
|
camera: new CameraHandle(core),
|
||||||
|
meshes: new MeshHandles(core).fromImportedScene(imported),
|
||||||
|
materials: new MaterialHandles(core).fromImportedScene(imported),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Property assignments remain direct SAB writes; no camera/material message is sent. */
|
||||||
|
export function restyleScene({ camera, materials }) {
|
||||||
|
camera.lookAt([4, 3, 6], [0, 0, 0]);
|
||||||
|
if (materials[0]) {
|
||||||
|
materials[0].baseColor = [0.2, 0.55, 1, 1];
|
||||||
|
materials[0].roughness = 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ Import the function you need and pass the same `YawnCore` instance to every addo
|
|||||||
| `05-default-pipelines.js` | Attaching optional external WGSL declarations |
|
| `05-default-pipelines.js` | Attaching optional external WGSL declarations |
|
||||||
| `06-custom-render-pipeline.js` | Supplying a custom scene render shader |
|
| `06-custom-render-pipeline.js` | Supplying a custom scene render shader |
|
||||||
| `07-compute-pipeline.js` | Supplying a binding-free compute pass |
|
| `07-compute-pipeline.js` | Supplying a binding-free compute pass |
|
||||||
| `08-compile-and-switch.js` | Compiling, activating, and safely cleaning up a graph |
|
| `08-compile-and-switch.js` | Compiling and transactionally activating a graph |
|
||||||
| `09-gltf-import-worker.js` | Fetching a glTF URL into shared memory from a worker |
|
| `09-gltf-import-worker.js` | Fetching a glTF URL into shared memory from a worker |
|
||||||
| `10-mesh-instances.js` | Creating and mutating conventional instance handles |
|
| `10-mesh-instances.js` | Creating and mutating conventional instance handles |
|
||||||
| `11-custom-soa-column.js` | Allocating an instance-sized shared SOA column |
|
| `11-custom-soa-column.js` | Allocating an instance-sized shared SOA column |
|
||||||
@@ -20,6 +20,8 @@ Import the function you need and pass the same `YawnCore` instance to every addo
|
|||||||
| `13-bvh-picking.js` | Ray picking through the mesh-handles addon |
|
| `13-bvh-picking.js` | Ray picking through the mesh-handles addon |
|
||||||
| `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` |
|
| `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` |
|
||||||
| `15-complete-scene.js` | Combining the graph, glTF, and mesh addons |
|
| `15-complete-scene.js` | Combining the graph, glTF, and mesh addons |
|
||||||
|
| `16-camera-render-data.js` | Treating camera input as direct render-data SAB writes |
|
||||||
|
| `17-conventional-handles.js` | Conventional camera and material properties over SAB rows |
|
||||||
|
|
||||||
Recipes 1–7 isolate graph authoring concepts, so their ASTs are intentionally
|
Recipes 1–7 isolate graph authoring concepts, so their ASTs are intentionally
|
||||||
fragments rather than complete renderable loadouts. Recipe 15 uses the complete
|
fragments rather than complete renderable loadouts. Recipe 15 uses the complete
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ export * from "./12-direct-sab-animation.js";
|
|||||||
export * from "./13-bvh-picking.js";
|
export * from "./13-bvh-picking.js";
|
||||||
export * from "./14-worker-to-worker.js";
|
export * from "./14-worker-to-worker.js";
|
||||||
export * from "./15-complete-scene.js";
|
export * from "./15-complete-scene.js";
|
||||||
|
export * from "./16-camera-render-data.js";
|
||||||
|
export * from "./17-conventional-handles.js";
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Runnable integration and focused addon recipes for the Yawn render core."
|
||||||
|
/>
|
||||||
|
<title>Yawn Examples</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
background: #0a0c0d;
|
||||||
|
color: #f3f1e8;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; }
|
||||||
|
body::before {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
content: "";
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 75% 8%, #bb5b2b26, transparent 28rem),
|
||||||
|
linear-gradient(#ffffff08 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #ffffff08 1px, transparent 1px),
|
||||||
|
#0a0c0d;
|
||||||
|
background-size: auto, 64px 64px, 64px 64px, auto;
|
||||||
|
}
|
||||||
|
a { color: inherit; }
|
||||||
|
.shell { width: min(1180px, calc(100% - 40px)); margin: auto; }
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 82px;
|
||||||
|
border-bottom: 1px solid #ffffff1a;
|
||||||
|
}
|
||||||
|
.wordmark { font-size: 18px; font-weight: 850; letter-spacing: .18em; }
|
||||||
|
.wordmark span { color: #da7543; }
|
||||||
|
header nav { display: flex; gap: 22px; color: #a9ada8; font-size: 13px; }
|
||||||
|
header nav a { text-decoration: none; }
|
||||||
|
header nav a:hover { color: #fff; }
|
||||||
|
.hero { padding: 88px 0 58px; max-width: 820px; }
|
||||||
|
.eyebrow,
|
||||||
|
.recipe-number {
|
||||||
|
color: #dc7543;
|
||||||
|
font: 700 11px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 16px 0 20px;
|
||||||
|
max-width: 780px;
|
||||||
|
font-size: clamp(48px, 8vw, 92px);
|
||||||
|
font-weight: 760;
|
||||||
|
letter-spacing: -.055em;
|
||||||
|
line-height: .94;
|
||||||
|
}
|
||||||
|
.hero p { max-width: 660px; color: #b0b3ae; font-size: 18px; line-height: 1.65; }
|
||||||
|
.primary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(270px, .6fr);
|
||||||
|
min-height: 340px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ffffff20;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #111416e8;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 160ms, transform 160ms;
|
||||||
|
}
|
||||||
|
.primary:hover { transform: translateY(-3px); border-color: #dc754388; }
|
||||||
|
.primary-copy { padding: clamp(30px, 5vw, 64px); }
|
||||||
|
.primary h2 { margin: 15px 0 12px; font-size: clamp(30px, 4vw, 52px); letter-spacing: -.035em; }
|
||||||
|
.primary p { max-width: 580px; color: #aaafaa; font-size: 16px; line-height: 1.65; }
|
||||||
|
.launch { display: inline-flex; align-items: center; gap: 10px; margin-top: 30px; font-weight: 720; }
|
||||||
|
.launch span { color: #dc7543; font-size: 22px; transition: transform 160ms; }
|
||||||
|
.primary:hover .launch span { transform: translateX(5px); }
|
||||||
|
.primary-art {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 260px;
|
||||||
|
border-left: 1px solid #ffffff16;
|
||||||
|
background: linear-gradient(145deg, #d57442, #6d2e1d);
|
||||||
|
}
|
||||||
|
.graph {
|
||||||
|
position: relative;
|
||||||
|
width: 190px;
|
||||||
|
height: 165px;
|
||||||
|
filter: drop-shadow(0 18px 25px #3d130b88);
|
||||||
|
}
|
||||||
|
.node { position: absolute; width: 78px; height: 48px; border: 1px solid #ffffff77; border-radius: 7px; background: #17191bd9; }
|
||||||
|
.node::after { position: absolute; inset: 10px 13px; content: ""; border-top: 3px solid #fff; border-bottom: 3px solid #ffffff55; }
|
||||||
|
.node:nth-child(1) { left: 0; top: 8px; }
|
||||||
|
.node:nth-child(2) { right: 0; top: 58px; }
|
||||||
|
.node:nth-child(3) { left: 20px; bottom: 0; }
|
||||||
|
.edge { position: absolute; height: 1px; transform-origin: left; background: #fff9; }
|
||||||
|
.edge-a { left: 70px; top: 48px; width: 77px; transform: rotate(19deg); }
|
||||||
|
.edge-b { left: 92px; top: 135px; width: 70px; transform: rotate(-40deg); }
|
||||||
|
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 94px 0 26px; }
|
||||||
|
.section-heading h2 { margin: 0; font-size: 34px; letter-spacing: -.03em; }
|
||||||
|
.section-heading p { margin: 0; color: #929792; }
|
||||||
|
.recipes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||||
|
.recipe {
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 25px;
|
||||||
|
border: 1px solid #ffffff17;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101315d9;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 140ms, border-color 140ms;
|
||||||
|
}
|
||||||
|
.recipe:hover { border-color: #dc75436b; background: #17191b; }
|
||||||
|
.recipe h3 { margin: 24px 0 8px; font-size: 17px; }
|
||||||
|
.recipe p { margin: 0; color: #989d98; font-size: 13px; line-height: 1.55; }
|
||||||
|
footer { display: flex; justify-content: space-between; gap: 20px; margin-top: 80px; padding: 34px 0 50px; border-top: 1px solid #ffffff1a; color: #858a85; font-size: 13px; }
|
||||||
|
code { color: #d5d0c4; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.primary { grid-template-columns: 1fr; }
|
||||||
|
.primary-art { min-height: 220px; border-top: 1px solid #ffffff16; border-left: 0; }
|
||||||
|
.recipes { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.shell { width: min(100% - 24px, 1180px); }
|
||||||
|
header nav a:first-child { display: none; }
|
||||||
|
.hero { padding-top: 60px; }
|
||||||
|
.recipes { grid-template-columns: 1fr; }
|
||||||
|
.section-heading, footer { align-items: start; flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<header>
|
||||||
|
<div class="wordmark">YAWN<span>.</span></div>
|
||||||
|
<nav aria-label="Examples navigation">
|
||||||
|
<a href="#recipes">Recipes</a>
|
||||||
|
<a href="https://github.com/heaust-ops/yawn">Repository ↗</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<div class="eyebrow">Worker-native rendering toolkit</div>
|
||||||
|
<h1>Build the graph.<br />Share the data.</h1>
|
||||||
|
<p>
|
||||||
|
Start with the complete interactive scene, then pull apart the focused
|
||||||
|
recipes for graph ASTs, external pipelines, glTF workers, mesh handles,
|
||||||
|
and direct shared-memory mutation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<a class="primary" href="./render-graph-studio/">
|
||||||
|
<div class="primary-copy">
|
||||||
|
<div class="eyebrow">Interactive example · all packages</div>
|
||||||
|
<h2>Render Graph Studio</h2>
|
||||||
|
<p>
|
||||||
|
Edit an FXNode graph, swap JSO loadouts, stream procedural glTF through
|
||||||
|
shared memory, pick instances, and orbit the shared-SOA camera.
|
||||||
|
</p>
|
||||||
|
<div class="launch">Launch studio <span>→</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="primary-art" aria-hidden="true">
|
||||||
|
<div class="graph">
|
||||||
|
<i class="node"></i><i class="node"></i><i class="node"></i>
|
||||||
|
<i class="edge edge-a"></i><i class="edge edge-b"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<section id="recipes" aria-labelledby="recipes-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 id="recipes-title">Focused recipes</h2>
|
||||||
|
<p>Small ES modules meant to copy, change, and combine.</p>
|
||||||
|
</div>
|
||||||
|
<div class="recipes">
|
||||||
|
<a class="recipe" href="./cookbook/01-canonical-ast.js"><span class="recipe-number">01 · AST</span><h3>Canonical DAG</h3><p>Share one graph output through references and serialize it as an S-expression.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/02-jso-graph.js"><span class="recipe-number">02 · JSO</span><h3>Plain object graph</h3><p>Describe a graph with ordinary JavaScript objects and compile to the canonical AST.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/03-fluent-builder.js"><span class="recipe-number">03 · JSO</span><h3>Fluent builder</h3><p>Author the same public graph contract with a compact chainable API.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/04-fxnode-export.js"><span class="recipe-number">04 · FXNode</span><h3>Snapshot export</h3><p>Turn an editor snapshot into the same AST consumed by every other frontend.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/05-default-pipelines.js"><span class="recipe-number">05 · Pipelines</span><h3>Default loadout</h3><p>Attach the optional external pipeline addon without placing shaders in core.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/06-custom-render-pipeline.js"><span class="recipe-number">06 · WGSL</span><h3>Custom render pipeline</h3><p>Provide scene WGSL, entry points, and state as graph-owned data.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/07-compute-pipeline.js"><span class="recipe-number">07 · Compute</span><h3>Compute pipeline</h3><p>Declare a compute shader and dispatch dimensions inside the graph loadout.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/08-compile-and-switch.js"><span class="recipe-number">08 · Lifecycle</span><h3>Compile and switch</h3><p>Prepare and transactionally activate compiled graph resources.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/09-gltf-import-worker.js"><span class="recipe-number">09 · glTF</span><h3>Shared import worker</h3><p>Parse a URL in a worker and write generic render data directly into shared SOA memory.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/10-mesh-instances.js"><span class="recipe-number">10 · Handles</span><h3>Mesh instances</h3><p>Use conventional generation-safe mesh and instance objects over the core protocol.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/11-custom-soa-column.js"><span class="recipe-number">11 · SOA</span><h3>Custom column</h3><p>Request an aligned instance-sized array, then mutate it without messages.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/12-direct-sab-animation.js"><span class="recipe-number">12 · SAB</span><h3>Direct animation</h3><p>Update guarded transforms at frame rate through shared memory.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/13-bvh-picking.js"><span class="recipe-number">13 · Picking</span><h3>BVH picking</h3><p>Ray-pick shared scene data using the optional mesh-handles worker.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/14-worker-to-worker.js"><span class="recipe-number">14 · Workers</span><h3>Worker to worker</h3><p>Run the public core API from any browser worker through a MessagePort.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/15-complete-scene.js"><span class="recipe-number">15 · Complete</span><h3>Complete scene</h3><p>Combine graph, pipeline, glTF import, and mesh addons end to end.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/16-camera-render-data.js"><span class="recipe-number">16 · Camera</span><h3>Camera render data</h3><p>Orbit, pan, and zoom by directly writing one SIMD-width SAB row.</p></a>
|
||||||
|
<a class="recipe" href="./cookbook/17-conventional-handles.js"><span class="recipe-number">17 · Handles</span><h3>Camera and materials</h3><p>Use familiar camera and material properties while retaining direct SAB mutations.</p></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer><span>Yawn examples</span><code>npm run examples</code></footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -21,7 +21,7 @@ export function encodeGeometryGlb({positions,normals,texcoords,indices}) {
|
|||||||
const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3);
|
const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3);
|
||||||
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry");
|
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry");
|
||||||
const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]});
|
const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]});
|
||||||
const json={asset:{version:"2.0",generator:"yawn-phase8"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[
|
const json={asset:{version:"2.0",generator:"yawn-demo"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[
|
||||||
{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},
|
{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},
|
||||||
{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},
|
{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},
|
||||||
{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},
|
{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},
|
||||||
@@ -57,7 +57,7 @@ const galleryPngBase64=Object.freeze({
|
|||||||
});
|
});
|
||||||
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
|
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
|
||||||
|
|
||||||
/** Build the deterministic Phase 6 PBR shader validation gallery. */
|
/** Build a deterministic PBR shader validation gallery. */
|
||||||
export function createMaterialGalleryGlb(){
|
export function createMaterialGalleryGlb(){
|
||||||
// A modest shared sphere keeps the embedded GLB compact while making roughness
|
// A modest shared sphere keeps the embedded GLB compact while making roughness
|
||||||
// and normal-map responses much easier to compare than the former cubes.
|
// and normal-map responses much easier to compare than the former cubes.
|
||||||
@@ -78,7 +78,7 @@ export function createMaterialGalleryGlb(){
|
|||||||
];
|
];
|
||||||
const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})}));
|
const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})}));
|
||||||
const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
|
const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
|
||||||
const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
|
const json={asset:{version:"2.0",generator:"yawn-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
|
||||||
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
|
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
|
||||||
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
|
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
|
||||||
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
|
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
|
||||||
|
|||||||
@@ -71,11 +71,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
#profile-menu { min-width: 210px; color: #9da9ba; }
|
|
||||||
#profile-menu summary { cursor: pointer; color: #eef2f8; }
|
|
||||||
#profile-menu table { width: 100%; font-size: 11px; }
|
|
||||||
#profile-menu th { text-align: left; font-weight: 500; }
|
|
||||||
#profile-menu td { text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
select,
|
select,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -165,7 +160,7 @@
|
|||||||
>Scene loadout<select id="loadout-select">
|
>Scene loadout<select id="loadout-select">
|
||||||
<option value="cubes">Cubes</option>
|
<option value="cubes">Cubes</option>
|
||||||
<option value="spheres">UV spheres</option>
|
<option value="spheres">UV spheres</option>
|
||||||
<option value="materials">Phase 6 PBR gallery</option>
|
<option value="materials">PBR material gallery</option>
|
||||||
</select></label
|
</select></label
|
||||||
><label class="field" for="graph-select"
|
><label class="field" for="graph-select"
|
||||||
>Graph preset<select id="graph-select">
|
>Graph preset<select id="graph-select">
|
||||||
@@ -175,7 +170,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="canvas0"></canvas
|
<canvas id="canvas0"></canvas
|
||||||
><output id="demo-status" aria-live="polite">Starting Phase 8…</output>
|
><output id="demo-status" aria-live="polite">Starting renderer…</output>
|
||||||
</section>
|
</section>
|
||||||
<section class="editor" aria-label="Render graph editor">
|
<section class="editor" aria-label="Render graph editor">
|
||||||
<div class="editor-bar">
|
<div class="editor-bar">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { YawnCore, RendererError } from "@yawn/core";
|
import { YawnCore, RendererError } from "@yawn/core";
|
||||||
import { MeshHandles, createPickingWorker } from "@yawn/mesh-handles";
|
import { MeshHandles } from "@yawn/mesh-handles";
|
||||||
|
import { installCameraRenderDataControls } from "../cookbook/16-camera-render-data.js";
|
||||||
import { loadDemoLoadout } from "./demo-loadouts.js";
|
import { loadDemoLoadout } from "./demo-loadouts.js";
|
||||||
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
||||||
import { createGraphAst } from "@yawn/render-graph-ast";
|
import { createGraphAst } from "@yawn/render-graph-ast";
|
||||||
@@ -37,11 +38,7 @@ const state = {
|
|||||||
compiled: {},
|
compiled: {},
|
||||||
telemetry: null,
|
telemetry: null,
|
||||||
};
|
};
|
||||||
export const profileRequested = (search) =>
|
function createWorkerTransport() {
|
||||||
new URLSearchParams(search).get("profile") === "1";
|
|
||||||
const profileEnabled = profileRequested(location.search);
|
|
||||||
|
|
||||||
function createWorkerTransport(profile) {
|
|
||||||
const canvas = document.querySelector("#canvas0");
|
const canvas = document.querySelector("#canvas0");
|
||||||
const dpr = devicePixelRatio;
|
const dpr = devicePixelRatio;
|
||||||
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
|
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
|
||||||
@@ -62,16 +59,6 @@ function createWorkerTransport(profile) {
|
|||||||
kind,
|
kind,
|
||||||
values: new Float64Array(values),
|
values: new Float64Array(values),
|
||||||
});
|
});
|
||||||
const mouseValues = (event) => [
|
|
||||||
devicePixelRatio,
|
|
||||||
event.buttons,
|
|
||||||
event.movementX,
|
|
||||||
event.movementY,
|
|
||||||
event.offsetX,
|
|
||||||
event.offsetY,
|
|
||||||
Math.max(1, canvas.clientHeight),
|
|
||||||
];
|
|
||||||
|
|
||||||
addEventListener(
|
addEventListener(
|
||||||
"resize",
|
"resize",
|
||||||
() =>
|
() =>
|
||||||
@@ -82,106 +69,19 @@ function createWorkerTransport(profile) {
|
|||||||
]),
|
]),
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
canvas.addEventListener(
|
|
||||||
"pointerdown",
|
|
||||||
(event) => {
|
|
||||||
if (event.pointerType === "mouse" && (event.button === 1 || event.button === 2)) {
|
|
||||||
event.preventDefault();
|
|
||||||
canvas.setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
canvas.addEventListener(
|
|
||||||
"pointermove",
|
|
||||||
(event) => {
|
|
||||||
if (
|
|
||||||
event.pointerType === "mouse" &&
|
|
||||||
canvas.hasPointerCapture(event.pointerId) &&
|
|
||||||
(event.buttons & 6) !== 0
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
post(1, mouseValues(event));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
for (const type of ["pointerup", "pointercancel"]) {
|
|
||||||
canvas.addEventListener(
|
|
||||||
type,
|
|
||||||
(event) => {
|
|
||||||
if (canvas.hasPointerCapture(event.pointerId)) {
|
|
||||||
canvas.releasePointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
canvas.addEventListener(
|
|
||||||
"click",
|
|
||||||
(event) => {
|
|
||||||
if (event.button === 0) post(2, mouseValues(event));
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
canvas.addEventListener(
|
|
||||||
"wheel",
|
|
||||||
(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const delta =
|
|
||||||
event.deltaMode === 0
|
|
||||||
? event.deltaY
|
|
||||||
: event.deltaMode === 1
|
|
||||||
? event.deltaY * 16
|
|
||||||
: event.deltaMode === 2
|
|
||||||
? event.deltaY * Math.max(1, canvas.clientHeight)
|
|
||||||
: null;
|
|
||||||
if (Number.isFinite(delta)) post(3, [delta]);
|
|
||||||
},
|
|
||||||
{ ...options, passive: false },
|
|
||||||
);
|
|
||||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options);
|
|
||||||
|
|
||||||
const offscreen = canvas.transferControlToOffscreen();
|
const offscreen = canvas.transferControlToOffscreen();
|
||||||
worker.postMessage({ type: "init", canvas: offscreen, profile }, [offscreen]);
|
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
|
||||||
return {
|
return {
|
||||||
worker,
|
worker,
|
||||||
pickingWorkerFactory: createPickingWorker,
|
|
||||||
free() {
|
free() {
|
||||||
abort.abort();
|
abort.abort();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function installProfileMenu() {
|
|
||||||
if (!profileEnabled) return;
|
|
||||||
const menu = document.createElement("details");
|
|
||||||
menu.id = "profile-menu";
|
|
||||||
menu.innerHTML =
|
|
||||||
'<summary>GPU profile</summary><div id="profile-status">Waiting for GPU timestamps…</div><table><tbody id="profile-passes"></tbody></table>';
|
|
||||||
document.querySelector(".toolbar")?.append(menu);
|
|
||||||
on(renderer, "renderer-profile", (event) => {
|
|
||||||
const p = event.detail;
|
|
||||||
document.querySelector("#profile-status").textContent = p.available
|
|
||||||
? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped`
|
|
||||||
: "GPU timestamps unavailable";
|
|
||||||
document.querySelector("#profile-passes").replaceChildren(
|
|
||||||
...Object.entries(p.passes || {}).map(([id, ms]) => {
|
|
||||||
const row = document.createElement("tr"),
|
|
||||||
name = document.createElement("th"),
|
|
||||||
value = document.createElement("td");
|
|
||||||
name.textContent = id;
|
|
||||||
value.textContent = `${Number(ms).toFixed(3)} ms`;
|
|
||||||
row.append(name, value);
|
|
||||||
return row;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function publish(telemetry) {
|
function publish(telemetry) {
|
||||||
state.telemetry = telemetry;
|
state.telemetry = telemetry;
|
||||||
document.documentElement.dataset.phase8State = JSON.stringify({
|
document.documentElement.dataset.yawnState = JSON.stringify({
|
||||||
activeLoadout: state.loadout,
|
activeLoadout: state.loadout,
|
||||||
activeGraph: state.graph,
|
activeGraph: state.graph,
|
||||||
renderDataRevision: telemetry.revision,
|
renderDataRevision: telemetry.revision,
|
||||||
@@ -195,7 +95,6 @@ function publish(telemetry) {
|
|||||||
draws: telemetry.draws,
|
draws: telemetry.draws,
|
||||||
instances: telemetry.instances,
|
instances: telemetry.instances,
|
||||||
indices: telemetry.indices,
|
indices: telemetry.indices,
|
||||||
framingRadius: telemetry.framingRadius,
|
|
||||||
gpuError: telemetry.gpuError,
|
gpuError: telemetry.gpuError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -252,9 +151,9 @@ async function transaction(label, operation, rollback) {
|
|||||||
try {
|
try {
|
||||||
await rollback?.();
|
await rollback?.();
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
console.error("Phase 8 rollback failed", rollbackError);
|
console.error("Render graph rollback failed", rollbackError);
|
||||||
}
|
}
|
||||||
console.error("Phase 8 transaction failed", error);
|
console.error("Render graph transaction failed", error);
|
||||||
status(`Failed · ${error?.code ?? error?.message ?? error}`);
|
status(`Failed · ${error?.code ?? error?.message ?? error}`);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -328,6 +227,7 @@ async function cleanup() {
|
|||||||
await editor?.destroy();
|
await editor?.destroy();
|
||||||
} finally {
|
} finally {
|
||||||
gltfImporter?.dispose();
|
gltfImporter?.dispose();
|
||||||
|
meshHandles?.dispose();
|
||||||
renderer?.dispose();
|
renderer?.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,12 +237,15 @@ const pagehide = () => {
|
|||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
addEventListener("pagehide", pagehide, { once: true });
|
addEventListener("pagehide", pagehide, { once: true });
|
||||||
delete document.documentElement.dataset.phase8Ready;
|
delete document.documentElement.dataset.yawnReady;
|
||||||
renderer = new YawnCore(createWorkerTransport(profileEnabled));
|
const transport = createWorkerTransport();
|
||||||
|
renderer = new YawnCore(transport);
|
||||||
meshHandles = new MeshHandles(renderer);
|
meshHandles = new MeshHandles(renderer);
|
||||||
gltfImporter = new GltfImporter(renderer);
|
gltfImporter = new GltfImporter(renderer);
|
||||||
installProfileMenu();
|
|
||||||
await renderer.ready;
|
await renderer.ready;
|
||||||
|
listeners.push(
|
||||||
|
installCameraRenderDataControls(renderer, document.querySelector("#canvas0")),
|
||||||
|
);
|
||||||
const nextEditor = await createRenderGraphEditor(
|
const nextEditor = await createRenderGraphEditor(
|
||||||
document.querySelector("#graph-editor"),
|
document.querySelector("#graph-editor"),
|
||||||
);
|
);
|
||||||
@@ -465,11 +368,11 @@ async function start() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!initialized) throw new Error("Initial demo transaction failed");
|
if (!initialized) throw new Error("Initial demo transaction failed");
|
||||||
document.documentElement.dataset.phase8Ready = "true";
|
document.documentElement.dataset.yawnReady = "true";
|
||||||
}
|
}
|
||||||
const startupError = (error) => {
|
const startupError = (error) => {
|
||||||
if (cleaned) return;
|
if (cleaned) return;
|
||||||
console.error("Phase 8 startup failed", error);
|
console.error("Render graph startup failed", error);
|
||||||
status(`Startup failed · ${error?.code ?? error}`);
|
status(`Startup failed · ${error?.code ?? error}`);
|
||||||
void cleanup();
|
void cleanup();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
cargo-features = ["profile-rustflags"]
|
|
||||||
|
|
||||||
[package]
|
|
||||||
name = "level-editor"
|
|
||||||
description = "Level editor application using the renderer"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
atomics = []
|
|
||||||
bulk-memory = []
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
|
|
||||||
[target.wasm32-unknown-unknown]
|
|
||||||
rustflags = [
|
|
||||||
"-Clink-args=--shared-memory",
|
|
||||||
"-Clink-args=--max-memory=1073741824",
|
|
||||||
"-Clink-args=--import-memory",
|
|
||||||
"-Clink-args=--export=__wasm_init_tls",
|
|
||||||
"-Clink-args=--export=__tls_size",
|
|
||||||
"-Clink-args=--export=__tls_align",
|
|
||||||
"-Clink-args=--export=__tls_base",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
renderer = { path = "../renderer" }
|
|
||||||
wasm-bindgen = { workspace = true }
|
|
||||||
wasm-bindgen-futures = { workspace = true }
|
|
||||||
console_error_panic_hook = { workspace = true }
|
|
||||||
log = { workspace = true }
|
|
||||||
wasm-logger = { workspace = true }
|
|
||||||
web-sys = { workspace = true }
|
|
||||||
js-sys = { workspace = true }
|
|
||||||
ultraviolet = { workspace = true }
|
|
||||||
wgpu = { workspace = true }
|
|
||||||
bytemuck = { workspace = true }
|
|
||||||
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
|
||||||
wasm-opt = false
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
#![cfg(target_arch = "wasm32")]
|
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
|
|
||||||
use renderer::camera::Camera;
|
|
||||||
use renderer::render_data::RenderData;
|
|
||||||
use renderer::renderer as gpu_renderer;
|
|
||||||
use renderer::renderer::scene::FrameMetadata;
|
|
||||||
|
|
||||||
struct EditorScene {
|
|
||||||
uniform_buffers: [wgpu::Buffer; 2],
|
|
||||||
bind_groups: [wgpu::BindGroup; 2],
|
|
||||||
frame_metadata: FrameMetadata,
|
|
||||||
cam: Camera,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl renderer::renderer::scene::Scene for EditorScene {
|
|
||||||
fn setup(
|
|
||||||
renderer_context: &gpu_renderer::RendererContext,
|
|
||||||
resources: &mut gpu_renderer::PipelineLibrary,
|
|
||||||
_render_data: &mut RenderData,
|
|
||||||
) -> Self {
|
|
||||||
let dimension = ultraviolet::Vec2::new(
|
|
||||||
renderer_context.surface_config.width as f32,
|
|
||||||
renderer_context.surface_config.height as f32,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut frame_metadata = FrameMetadata::new(dimension);
|
|
||||||
let camera = Camera::new(dimension.x / dimension.y);
|
|
||||||
|
|
||||||
frame_metadata.set_camera_position(camera.position());
|
|
||||||
|
|
||||||
let uniform_resource = frame_metadata.create_uniform_resource(&renderer_context.device);
|
|
||||||
let camera_resource = camera.create_uniform_resource(&renderer_context.device);
|
|
||||||
|
|
||||||
let bind_group_layouts = [
|
|
||||||
uniform_resource.bind_group_layout,
|
|
||||||
camera_resource.bind_group_layout,
|
|
||||||
];
|
|
||||||
|
|
||||||
resources.set_bind_group_layouts(&bind_group_layouts);
|
|
||||||
|
|
||||||
EditorScene {
|
|
||||||
uniform_buffers: [uniform_resource.buffer, camera_resource.buffer],
|
|
||||||
bind_groups: [uniform_resource.bind_group, camera_resource.bind_group],
|
|
||||||
frame_metadata,
|
|
||||||
cam: camera,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
|
|
||||||
Some(&mut self.frame_metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn camera_mut(&mut self) -> Option<&mut Camera> {
|
|
||||||
Some(&mut self.cam)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
|
|
||||||
Some([&self.uniform_buffers[0], &self.uniform_buffers[1]])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind_groups(&self) -> &[wgpu::BindGroup] {
|
|
||||||
&self.bind_groups
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_mouse_click(&mut self, x: f32, y: f32) {
|
|
||||||
self.frame_metadata.mouse_click = [x, y];
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_zoom(&mut self, delta_y: f32) {
|
|
||||||
self.cam.zoom(delta_y);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) {
|
|
||||||
self.cam.orbit(delta_x, delta_y);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
|
|
||||||
self.cam.pan(delta_x, delta_y, viewport_height);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_camera_depth_range(&mut self, near: f32, far: f32) {
|
|
||||||
self.cam.set_depth_range(near, far);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3) {
|
|
||||||
self.cam.look_at(eye, center);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the level editor inside its owning render worker.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn worker_main(profile: bool) -> u32 {
|
|
||||||
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
|
|
||||||
wasm_logger::init(wasm_logger::Config::default());
|
|
||||||
renderer::app_setup::worker_entrypoint::<EditorScene>(profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return this worker's shared WebAssembly memory to messaging clients.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn worker_memory() -> JsValue {
|
|
||||||
wasm_bindgen::memory()
|
|
||||||
}
|
|
||||||
+5
-9
@@ -9,21 +9,17 @@
|
|||||||
"addons/*"
|
"addons/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "run-s rsw:build dev:watch",
|
"examples": "run-s rsw:build examples:watch",
|
||||||
"dev:watch": "run-p rsw:watch dev:vite",
|
"examples:watch": "run-p rsw:watch examples:vite",
|
||||||
"dev:vite": "vite dev",
|
"examples:vite": "vite dev",
|
||||||
"rsw:watch": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw watch",
|
"rsw:watch": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw watch",
|
||||||
"rsw:build": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw build",
|
"rsw:build": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw build",
|
||||||
"wasm-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web level-editor -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
"wasm-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
||||||
"wasm-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web level-editor -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
"wasm-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
||||||
"wasm-renderer-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
|
||||||
"wasm-renderer-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
|
|
||||||
"bundle-dev": "vite build --mode development",
|
"bundle-dev": "vite build --mode development",
|
||||||
"bundle-release": "vite build",
|
"bundle-release": "vite build",
|
||||||
"build": "run-s clean wasm-dev bundle-dev",
|
"build": "run-s clean wasm-dev bundle-dev",
|
||||||
"build-release": "run-s clean wasm-release bundle-release",
|
"build-release": "run-s clean wasm-release bundle-release",
|
||||||
"build-renderer": "run-s clean wasm-renderer-dev bundle-dev",
|
|
||||||
"build-renderer-release": "run-s clean wasm-renderer-release bundle-release",
|
|
||||||
"test:js": "node --test tests/*.test.js",
|
"test:js": "node --test tests/*.test.js",
|
||||||
"start": "vite preview",
|
"start": "vite preview",
|
||||||
"clean": "rimraf --glob dist **/pkg",
|
"clean": "rimraf --glob dist **/pkg",
|
||||||
|
|||||||
@@ -2,8 +2,5 @@
|
|||||||
"name": "@yawn/core",
|
"name": "@yawn/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": "./src/index.js"
|
||||||
".": "./src/index.js",
|
|
||||||
"./snapshot": "./src/snapshot.js"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { SnapshotReader } from "./snapshot.js";
|
|
||||||
|
|
||||||
const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
|
const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
|
||||||
const OP = { IMPORT_GLB: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 };
|
const OP = { INSTALL_RENDER_DATA: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 };
|
||||||
|
|
||||||
export class RendererError extends Error {
|
export class RendererError extends Error {
|
||||||
constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; }
|
constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; }
|
||||||
@@ -12,9 +10,8 @@ export class YawnCore extends EventTarget {
|
|||||||
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
|
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
|
||||||
#readyResolve; #readyReject; #arrays = new Map();
|
#readyResolve; #readyReject; #arrays = new Map();
|
||||||
#transportReady = false;
|
#transportReady = false;
|
||||||
#telemetry; #profile; #stopped = false;
|
#telemetry; #stopped = false; #renderDataSnapshot;
|
||||||
#graphQueue = []; #graphBusy = false;
|
#graphQueue = []; #graphBusy = false;
|
||||||
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
|
|
||||||
|
|
||||||
constructor(bridge) {
|
constructor(bridge) {
|
||||||
super();
|
super();
|
||||||
@@ -31,20 +28,11 @@ export class YawnCore extends EventTarget {
|
|||||||
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
||||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
|
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
|
||||||
this.#worker.start?.();
|
this.#worker.start?.();
|
||||||
try {
|
|
||||||
const factory = bridge.pickingWorkerFactory;
|
|
||||||
if (factory) {
|
|
||||||
this.#bvh = factory();
|
|
||||||
this.#bvh.addEventListener("message", e => this.#bvhMessage(e.data));
|
|
||||||
this.#bvh.addEventListener("error", () => this.#disablePicking("PICKING_FAILED"));
|
|
||||||
this.#bvh.addEventListener("messageerror", () => this.#disablePicking("PICKING_FAILED"));
|
|
||||||
} else this.#picking = false;
|
|
||||||
} catch { this.#picking = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get ready() { return this.#ready; }
|
get ready() { return this.#ready; }
|
||||||
get telemetry() { return this.#telemetry; }
|
get telemetry() { return this.#telemetry; }
|
||||||
get profile() { return this.#profile; }
|
get renderDataSnapshot() { return this.#renderDataSnapshot; }
|
||||||
|
|
||||||
array(name) {
|
array(name) {
|
||||||
const array = this.#arrays.get(name);
|
const array = this.#arrays.get(name);
|
||||||
@@ -99,9 +87,6 @@ export class YawnCore extends EventTarget {
|
|||||||
} else if (message?.type === "telemetry") {
|
} else if (message?.type === "telemetry") {
|
||||||
this.#telemetry = message;
|
this.#telemetry = message;
|
||||||
this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
|
this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
|
||||||
} else if (message?.type === "profile-snapshot") {
|
|
||||||
this.#profile = message;
|
|
||||||
this.dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
|
|
||||||
} else if (message?.type === "fatal") {
|
} else if (message?.type === "fatal") {
|
||||||
console.error("renderer worker fatal", JSON.stringify(message));
|
console.error("renderer worker fatal", JSON.stringify(message));
|
||||||
this.#fail(message.code || "WORKER_FATAL");
|
this.#fail(message.code || "WORKER_FATAL");
|
||||||
@@ -117,11 +102,18 @@ export class YawnCore extends EventTarget {
|
|||||||
} else if (message?.type === "snapshot-init") {
|
} else if (message?.type === "snapshot-init") {
|
||||||
try {
|
try {
|
||||||
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
|
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
|
||||||
this.#snapshotReader = new SnapshotReader(this.#bridge.memory, message.controlPtr);
|
this.#renderDataSnapshot = Object.freeze({
|
||||||
this.#bvh?.postMessage({type:"init",memory:this.#bridge.memory,controlPtr:message.controlPtr,controlVersion:1,schemaVersion:2});
|
memory: this.#bridge.memory,
|
||||||
} catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
|
controlPtr: message.controlPtr,
|
||||||
|
controlVersion: message.controlVersion,
|
||||||
|
schemaVersion: message.schemaVersion,
|
||||||
|
});
|
||||||
|
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot", { detail: this.#renderDataSnapshot }));
|
||||||
|
} catch { this.#fail("SNAPSHOT_PROTOCOL_MISMATCH"); }
|
||||||
} else if (message?.type === "snapshot-published") {
|
} else if (message?.type === "snapshot-published") {
|
||||||
try { this.#snapshotEpoch=this.#snapshotReader?.latest().epoch||0; this.#bvh?.postMessage({type:"update",epoch:this.#snapshotEpoch}); } catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
|
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot-published", {
|
||||||
|
detail: Object.freeze({ epoch: message.epoch >>> 0 }),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,30 +124,10 @@ export class YawnCore extends EventTarget {
|
|||||||
return this.#arrays.get(descriptor.name);
|
return this.#arrays.get(descriptor.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#disablePicking(code) { this.#picking=false; const allowed=new Set(["PICK_UNAVAILABLE","PICK_PROTOCOL_MISMATCH","PICK_WORKER_ERROR","PICK_STALE","DISPOSED"]); const error=new RendererError(allowed.has(code)?code:"PICK_WORKER_ERROR"); for(const p of this.#picks.values())p.reject(error); this.#picks.clear(); try{this.#bvh?.terminate?.();}catch{} this.#bvh=null; }
|
|
||||||
#bvhMessage(message) {
|
|
||||||
if(message?.type==="fatal"){this.#disablePicking(message.code);return;}
|
|
||||||
if(message?.type!=="pick")return;
|
|
||||||
const p=this.#picks.get(message.request);if(!p)return;this.#picks.delete(message.request);
|
|
||||||
let latest=0;try{latest=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=latest;}catch{this.#disablePicking("PICK_PROTOCOL_MISMATCH");p.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));return;}
|
|
||||||
if(message.stale||p.epoch!==message.epoch||message.epoch!==latest){if(!p.retried&&latest){this.#sendPick({...p,retried:true},latest);}else p.reject(new RendererError("PICK_STALE"));return;}
|
|
||||||
const hits=(message.hits||[]).map(hit=>({instance:[hit.slot>>>0,hit.generation>>>0],distance:hit.distance}));p.resolve({epoch:latest,hits});
|
|
||||||
}
|
|
||||||
#sendPick(p,epoch){const request=this.#pickNext++>>>0||this.#pickNext++;p.epoch=epoch;this.#picks.set(request,p);try{this.#bvh.postMessage({type:"pick",request,epoch,origin:p.origin,direction:p.direction,maxDistance:p.maxDistance,maxHits:p.maxHits});}catch{this.#picks.delete(request);p.reject(new RendererError("PICK_WORKER_ERROR"));}}
|
|
||||||
|
|
||||||
pickRay(origin,direction,{maxDistance=Infinity,maxHits=1}={}) {
|
|
||||||
const vector=(v,name)=>{if(!v||v.length!==3||[...v].some(x=>typeof x!=="number"||!Number.isFinite(x)))throw new TypeError(`${name} must contain 3 finite numbers`);return [...v];};
|
|
||||||
origin=vector(origin,"origin");direction=vector(direction,"direction");if(direction.every(x=>x===0))throw new TypeError("direction must be nonzero");if(typeof maxDistance!=="number"||(!(Number.isFinite(maxDistance)&&maxDistance>=0)&&maxDistance!==Infinity)||!Number.isInteger(maxHits)||maxHits<1||maxHits>64)throw new TypeError("invalid pick options");
|
|
||||||
if(this.#disposed)return Promise.reject(new RendererError("DISPOSED"));if(!this.#picking||!this.#bvh||!this.#snapshotReader)return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
|
|
||||||
let epoch;try{epoch=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=epoch;}catch{return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));}if(!epoch)return Promise.reject(new RendererError("PICK_STALE"));
|
|
||||||
return new Promise((resolve,reject)=>this.#sendPick({resolve,reject,origin,direction,maxDistance,maxHits,retried:false},epoch));
|
|
||||||
}
|
|
||||||
|
|
||||||
#stop() {
|
#stop() {
|
||||||
if (this.#stopped) return;
|
if (this.#stopped) return;
|
||||||
this.#stopped = true;
|
this.#stopped = true;
|
||||||
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
||||||
try { this.#bvh?.postMessage?.({type:"dispose"}); this.#bvh?.terminate?.(); } catch { /* best effort */ }
|
|
||||||
try { this.#bridge?.free?.(); } catch { /* best effort */ }
|
try { this.#bridge?.free?.(); } catch { /* best effort */ }
|
||||||
this.#bridge = null;
|
this.#bridge = null;
|
||||||
}
|
}
|
||||||
@@ -171,7 +143,6 @@ export class YawnCore extends EventTarget {
|
|||||||
this.#payloadPending.clear();
|
this.#payloadPending.clear();
|
||||||
for (const pending of this.#graphQueue) pending.reject(error);
|
for (const pending of this.#graphQueue) pending.reject(error);
|
||||||
this.#graphQueue.length = 0;
|
this.#graphQueue.length = 0;
|
||||||
this.#disablePicking(code === "DISPOSED" ? "DISPOSED" : "PICK_WORKER_ERROR");
|
|
||||||
this.#stop();
|
this.#stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,14 +182,13 @@ export class YawnCore extends EventTarget {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
commitGlbUpload(array, byteLength, { framing = "exterior" } = {}) {
|
commitRenderDataUpload(array, byteLength) {
|
||||||
if (this.#disposed) throw new RendererError("DISPOSED");
|
if (this.#disposed) throw new RendererError("DISPOSED");
|
||||||
if (framing !== "exterior" && framing !== "interior") throw new TypeError("framing must be exterior or interior");
|
|
||||||
if (!(array instanceof SharedSoaArray) || array.domain !== "fixed" || array.scalar !== "u32" || array.stride !== array.lanes * 4)
|
if (!(array instanceof SharedSoaArray) || array.domain !== "fixed" || array.scalar !== "u32" || array.stride !== array.lanes * 4)
|
||||||
throw new TypeError("array must be a packed fixed uint32 shared array");
|
throw new TypeError("array must be a packed fixed uint32 shared array");
|
||||||
if (!Number.isInteger(byteLength) || byteLength < 1 || byteLength > array.length * array.lanes * 4)
|
if (!Number.isInteger(byteLength) || byteLength < 1 || byteLength > array.length * array.lanes * 4)
|
||||||
throw new RangeError("byteLength is outside the shared array");
|
throw new RangeError("byteLength is outside the shared array");
|
||||||
return this.#enqueue(OP.IMPORT_GLB, [array.id, byteLength, framing === "interior" ? 1 : 0]);
|
return this.#enqueue(OP.INSTALL_RENDER_DATA, [array.id, byteLength]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) {
|
async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) {
|
||||||
@@ -306,11 +276,7 @@ export class YawnCore extends EventTarget {
|
|||||||
switchCompiledGraph(compiledId) {
|
switchCompiledGraph(compiledId) {
|
||||||
validateCompiledId(compiledId);
|
validateCompiledId(compiledId);
|
||||||
if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero");
|
if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero");
|
||||||
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [1, ...compiledId]));
|
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, compiledId));
|
||||||
}
|
|
||||||
|
|
||||||
switchToImmediate() {
|
|
||||||
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [0, 0, 0]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() { this.#fail("DISPOSED"); }
|
dispose() { this.#fail("DISPOSED"); }
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
cargo-features = ["profile-rustflags"]
|
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "renderer"
|
name = "renderer"
|
||||||
description = "WGPU renderer core library"
|
description = "WGPU renderer core library"
|
||||||
@@ -14,38 +12,18 @@ default = []
|
|||||||
atomics = []
|
atomics = []
|
||||||
bulk-memory = []
|
bulk-memory = []
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
|
|
||||||
[target.wasm32-unknown-unknown]
|
|
||||||
rustflags = [
|
|
||||||
"-Clink-args=--shared-memory",
|
|
||||||
"-Clink-args=--max-memory=1073741824",
|
|
||||||
"-Clink-args=--import-memory",
|
|
||||||
"-Clink-args=--export=__wasm_init_tls",
|
|
||||||
"-Clink-args=--export=__tls_size",
|
|
||||||
"-Clink-args=--export=__tls_align",
|
|
||||||
"-Clink-args=--export=__tls_base",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wasm-bindgen = { workspace = true }
|
wasm-bindgen = { workspace = true }
|
||||||
wasm-bindgen-futures = { workspace = true }
|
wasm-bindgen-futures = { workspace = true }
|
||||||
console_error_panic_hook = { workspace = true }
|
console_error_panic_hook = { workspace = true }
|
||||||
console_log = { workspace = true }
|
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
wasm-logger = { workspace = true }
|
wasm-logger = { workspace = true }
|
||||||
web-sys = { workspace = true }
|
web-sys = { workspace = true }
|
||||||
js-sys = { workspace = true }
|
js-sys = { workspace = true }
|
||||||
bytemuck = { workspace = true }
|
bytemuck = { workspace = true }
|
||||||
cgmath = { workspace = true }
|
|
||||||
raw-window-handle = { workspace = true }
|
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
ultraviolet = { workspace = true }
|
ultraviolet = { workspace = true }
|
||||||
futures = { workspace = true }
|
|
||||||
gltf = { workspace = true }
|
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ use wasm_bindgen::prelude::*;
|
|||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
use crate::command_ring::CommandRing;
|
use crate::command_ring::CommandRing;
|
||||||
use crate::message::{MouseMessage, ResizeMessage, WheelMessage, WindowEvent};
|
|
||||||
use crate::platform::web::worker;
|
use crate::platform::web::worker;
|
||||||
|
use crate::renderer::ResizeMessage;
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static WORKER_EVENTS: RefCell<Option<mpsc::Sender<WindowEvent>>> = const { RefCell::new(None) };
|
static WORKER_EVENTS: RefCell<Option<mpsc::Sender<ResizeMessage>>> = const { RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deliver a low-frequency browser event to the worker-owned renderer channel.
|
/// Deliver a low-frequency browser event to the worker-owned renderer channel.
|
||||||
@@ -20,30 +20,11 @@ pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
|
|||||||
let values = values.to_vec();
|
let values = values.to_vec();
|
||||||
let value = |index: usize| values.get(index).copied().unwrap_or_default();
|
let value = |index: usize| values.get(index).copied().unwrap_or_default();
|
||||||
let event = match kind {
|
let event = match kind {
|
||||||
0 => WindowEvent::Resize(ResizeMessage {
|
0 => ResizeMessage {
|
||||||
width: value(0),
|
width: value(0),
|
||||||
height: value(1),
|
height: value(1),
|
||||||
scale_factor: value(2),
|
scale_factor: value(2),
|
||||||
}),
|
},
|
||||||
1 | 2 => {
|
|
||||||
let message = MouseMessage {
|
|
||||||
scale_factor: value(0),
|
|
||||||
buttons: value(1) as u16,
|
|
||||||
movement_x: value(2),
|
|
||||||
movement_y: value(3),
|
|
||||||
offset_x: value(4),
|
|
||||||
offset_y: value(5),
|
|
||||||
viewport_height: value(6),
|
|
||||||
};
|
|
||||||
if kind == 1 {
|
|
||||||
WindowEvent::PointerMove(message)
|
|
||||||
} else {
|
|
||||||
WindowEvent::PointerClick(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3 => WindowEvent::PointerWheel(WheelMessage {
|
|
||||||
delta_y_pixels: value(0) as f32,
|
|
||||||
}),
|
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
WORKER_EVENTS.with(|sender| {
|
WORKER_EVENTS.with(|sender| {
|
||||||
@@ -54,7 +35,7 @@ pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start the typed renderer and return its SAB command-ring pointer.
|
/// Start the typed renderer and return its SAB command-ring pointer.
|
||||||
pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bool) -> u32 {
|
pub fn worker_entrypoint() -> u32 {
|
||||||
let (sender, events) = mpsc::channel();
|
let (sender, events) = mpsc::channel();
|
||||||
// The render worker owns this allocation for its entire lifetime. Publishing a
|
// The render worker owns this allocation for its entire lifetime. Publishing a
|
||||||
// stable address lets every connected thread use the same shared command ring.
|
// stable address lets every connected thread use the same shared command ring.
|
||||||
@@ -62,7 +43,7 @@ pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bo
|
|||||||
let ring_ptr = ring.ptr();
|
let ring_ptr = ring.ptr();
|
||||||
WORKER_EVENTS.with(|worker_events| *worker_events.borrow_mut() = Some(sender));
|
WORKER_EVENTS.with(|worker_events| *worker_events.borrow_mut() = Some(sender));
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
worker::run_render_loop::<T>(events, ring, profile).await;
|
worker::run_render_loop(events, ring).await;
|
||||||
});
|
});
|
||||||
ring_ptr
|
ring_ptr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,543 +0,0 @@
|
|||||||
use std::f32::consts::PI;
|
|
||||||
|
|
||||||
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
|
|
||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
use crate::renderer::scene::UniformResource;
|
|
||||||
|
|
||||||
/// A camera matrix cannot produce a safe, meaningful frustum.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
|
||||||
pub enum FrustumError {
|
|
||||||
#[error("frustum plane {plane} contains a non-finite component")]
|
|
||||||
NonFinite { plane: usize },
|
|
||||||
#[error("frustum plane {plane} has a near-degenerate normal")]
|
|
||||||
Degenerate { plane: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_DISTANCE: f32 = 0.1;
|
|
||||||
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
|
|
||||||
const ORBIT_SENSITIVITY: f32 = 0.005;
|
|
||||||
const ZOOM_SENSITIVITY: f32 = 0.002;
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
pub struct Camera {
|
|
||||||
// Hot data - cached computed matrix (64 bytes, 1 cache line)
|
|
||||||
pub view_proj: [[f32; 4]; 4],
|
|
||||||
|
|
||||||
// Warm data - frequently accessed vectors (36 bytes)
|
|
||||||
position: Vec3,
|
|
||||||
target: Vec3,
|
|
||||||
up: Vec3,
|
|
||||||
|
|
||||||
// Cold data - projection parameters (16 bytes)
|
|
||||||
fov: f32,
|
|
||||||
aspect_ratio: f32,
|
|
||||||
z_near: f32,
|
|
||||||
z_far: f32,
|
|
||||||
|
|
||||||
// Rotor orientation for orbit camera behaviour
|
|
||||||
rotor: Rotor3,
|
|
||||||
distance: f32,
|
|
||||||
|
|
||||||
// Dirty flag for lazy evaluation
|
|
||||||
dirty: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OrthonormalBasis {
|
|
||||||
right: Vec3,
|
|
||||||
up: Vec3,
|
|
||||||
forward: Vec3,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OrthonormalBasis {
|
|
||||||
pub fn new(right: Vec3, up: Vec3, forward: Vec3) -> Self {
|
|
||||||
Self { right, up, forward }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_camera(camera: &Camera) -> Self {
|
|
||||||
let mut forward_offset = camera.target - camera.position;
|
|
||||||
if forward_offset.mag_sq() <= f32::EPSILON {
|
|
||||||
forward_offset = -Vec3::unit_z();
|
|
||||||
}
|
|
||||||
|
|
||||||
let forward = forward_offset.normalized();
|
|
||||||
|
|
||||||
let mut right = forward.cross(camera.up);
|
|
||||||
|
|
||||||
// Check if right vector is near zero (forward and up are parallel)
|
|
||||||
if right.mag_sq() < 1e-10 {
|
|
||||||
// Try alternate axes to find a valid right vector
|
|
||||||
let alternate_axes = [Vec3::unit_y(), Vec3::unit_x()];
|
|
||||||
for axis in alternate_axes.iter() {
|
|
||||||
right = forward.cross(*axis);
|
|
||||||
if right.mag_sq() >= 1e-10 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
right = right.normalized();
|
|
||||||
let up = right.cross(forward).normalized();
|
|
||||||
|
|
||||||
Self::new(right, up, forward)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
|
|
||||||
pub struct CameraUniform {
|
|
||||||
view_proj: [[f32; 4]; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Camera {
|
|
||||||
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
|
|
||||||
extract_frustum_planes(self.view_proj)
|
|
||||||
}
|
|
||||||
pub fn new(aspect_ratio: f32) -> Self {
|
|
||||||
let mut camera = Camera {
|
|
||||||
view_proj: [[0.0; 4]; 4],
|
|
||||||
position: Vec3::new(0.0, 0.5, 3.0),
|
|
||||||
target: Vec3::new(0.0, 0.0, 0.0),
|
|
||||||
up: Vec3::unit_y(),
|
|
||||||
fov: PI / 3.0,
|
|
||||||
aspect_ratio,
|
|
||||||
z_near: 0.1,
|
|
||||||
z_far: 100000.0,
|
|
||||||
rotor: Rotor3::identity(),
|
|
||||||
distance: 1.0,
|
|
||||||
dirty: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
camera.compute_rotor();
|
|
||||||
camera.compute_view_proj_mat();
|
|
||||||
|
|
||||||
camera
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compute_view_proj_mat(&mut self) {
|
|
||||||
let view = Mat4::look_at(self.position, self.target, self.up);
|
|
||||||
let proj = projection::rh_yup::perspective_wgpu_dx(
|
|
||||||
self.fov,
|
|
||||||
self.aspect_ratio,
|
|
||||||
self.z_near,
|
|
||||||
self.z_far,
|
|
||||||
);
|
|
||||||
self.view_proj = (proj * view).into();
|
|
||||||
self.dirty = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
|
|
||||||
if !vec3_is_finite(position) || !vec3_is_finite(target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.position = position;
|
|
||||||
self.target = target;
|
|
||||||
if (self.position - self.target).mag_sq() <= f32::EPSILON {
|
|
||||||
self.position = self.target + Vec3::unit_z() * MIN_DISTANCE;
|
|
||||||
}
|
|
||||||
self.up = Vec3::unit_y();
|
|
||||||
self.compute_rotor();
|
|
||||||
self.dirty = true;
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_depth_range(&mut self, z_near: f32, z_far: f32) {
|
|
||||||
self.z_near = z_near;
|
|
||||||
self.z_far = z_far.max(z_near + f32::EPSILON);
|
|
||||||
self.dirty = true;
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn position(&self) -> Vec3 {
|
|
||||||
self.position
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
|
|
||||||
self.aspect_ratio = aspect_ratio;
|
|
||||||
self.dirty = true;
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
|
|
||||||
if !delta_x.is_finite() || !delta_y.is_finite() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Skip tiny movements to reduce unnecessary computations
|
|
||||||
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let yaw_theta = delta_x * ORBIT_SENSITIVITY;
|
|
||||||
let yaw_rotor =
|
|
||||||
Rotor3::from_angle_plane(yaw_theta, Bivec3::from_normalized_axis(Vec3::unit_y()));
|
|
||||||
|
|
||||||
let basis = OrthonormalBasis::from_camera(self);
|
|
||||||
|
|
||||||
let pitch_angle = (delta_y * ORBIT_SENSITIVITY).clamp(-MAX_PITCH, MAX_PITCH);
|
|
||||||
|
|
||||||
let pitch_rotor =
|
|
||||||
Rotor3::from_angle_plane(pitch_angle, Bivec3::from_normalized_axis(basis.right));
|
|
||||||
|
|
||||||
let orbit_rotor = (yaw_rotor * pitch_rotor).normalized();
|
|
||||||
|
|
||||||
self.rotor = (orbit_rotor * self.rotor).normalized();
|
|
||||||
|
|
||||||
let mut offset = self.position - self.target;
|
|
||||||
if offset.mag_sq() <= f32::EPSILON {
|
|
||||||
offset = Vec3::unit_z() * self.distance.max(MIN_DISTANCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
orbit_rotor.rotate_vec(&mut offset);
|
|
||||||
self.distance = offset.mag().max(MIN_DISTANCE);
|
|
||||||
self.position = offset + self.target;
|
|
||||||
|
|
||||||
self.dirty = true;
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn zoom(&mut self, delta_y_pixels: f32) {
|
|
||||||
if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut offset = self.position - self.target;
|
|
||||||
let mut current_distance = offset.mag();
|
|
||||||
if !current_distance.is_finite() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if current_distance <= f32::EPSILON {
|
|
||||||
offset = Vec3::unit_z() * MIN_DISTANCE;
|
|
||||||
current_distance = MIN_DISTANCE;
|
|
||||||
}
|
|
||||||
let direction = offset / current_distance;
|
|
||||||
let max_distance = (self.z_far * 0.95).max(MIN_DISTANCE);
|
|
||||||
if !max_distance.is_finite() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let candidate = f64::from(current_distance)
|
|
||||||
* (f64::from(ZOOM_SENSITIVITY) * f64::from(delta_y_pixels)).exp();
|
|
||||||
let new_distance = candidate.clamp(f64::from(MIN_DISTANCE), f64::from(max_distance)) as f32;
|
|
||||||
let new_position = self.target + direction * new_distance;
|
|
||||||
if !vec3_is_finite(new_position) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.position = new_position;
|
|
||||||
self.distance = new_distance;
|
|
||||||
self.dirty = true;
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
|
|
||||||
if !delta_x.is_finite()
|
|
||||||
|| !delta_y.is_finite()
|
|
||||||
|| !viewport_height.is_finite()
|
|
||||||
|| !self.fov.is_finite()
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let distance = (self.position - self.target).mag().max(MIN_DISTANCE);
|
|
||||||
if !distance.is_finite() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let world_units_per_pixel =
|
|
||||||
2.0 * distance * (self.fov * 0.5).tan() / viewport_height.max(1.0);
|
|
||||||
let basis = OrthonormalBasis::from_camera(self);
|
|
||||||
let translation = (-basis.right * delta_x + basis.up * delta_y) * world_units_per_pixel;
|
|
||||||
let position = self.position + translation;
|
|
||||||
let target = self.target + translation;
|
|
||||||
if !vec3_is_finite(position) || !vec3_is_finite(target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.position = position;
|
|
||||||
self.target = target;
|
|
||||||
self.dirty = true;
|
|
||||||
self.compute_view_proj_mat();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
|
|
||||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
||||||
label: "camera uniform buffer".into(),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
contents: bytemuck::cast_slice(&[self.view_proj]),
|
|
||||||
});
|
|
||||||
|
|
||||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
||||||
label: Some("Camera bind group layout"),
|
|
||||||
entries: &[wgpu::BindGroupLayoutEntry {
|
|
||||||
binding: 0,
|
|
||||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
|
||||||
ty: wgpu::BindingType::Buffer {
|
|
||||||
ty: wgpu::BufferBindingType::Uniform,
|
|
||||||
has_dynamic_offset: false,
|
|
||||||
min_binding_size: None,
|
|
||||||
},
|
|
||||||
count: None,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("Camera bind group"),
|
|
||||||
layout: &bind_group_layout,
|
|
||||||
entries: &[wgpu::BindGroupEntry {
|
|
||||||
binding: 0,
|
|
||||||
resource: buffer.as_entire_binding(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
UniformResource {
|
|
||||||
buffer,
|
|
||||||
bind_group,
|
|
||||||
bind_group_layout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_rotor(&mut self) {
|
|
||||||
let offset = self.position - self.target;
|
|
||||||
let distance = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
|
|
||||||
self.distance = distance.max(MIN_DISTANCE);
|
|
||||||
|
|
||||||
// to compute the initial rotor we will do two rotations
|
|
||||||
// these will orient the camera to the new coordinates
|
|
||||||
//
|
|
||||||
|
|
||||||
// but first we need the orthonormal basis for the current camera
|
|
||||||
let basis = OrthonormalBasis::from_camera(self);
|
|
||||||
|
|
||||||
// first rotation
|
|
||||||
// this is the swing to make position face the target
|
|
||||||
let camera_local_up = Vec3::unit_z();
|
|
||||||
let swing_rotor = Rotor3::from_rotation_between(camera_local_up, -basis.forward);
|
|
||||||
|
|
||||||
// now we need a twist rotor which aligns the camera up
|
|
||||||
let mut up_after_swing = self.up.clone();
|
|
||||||
swing_rotor.rotate_vec(&mut up_after_swing);
|
|
||||||
|
|
||||||
// to rotate a vector by a rotor we need
|
|
||||||
// - a bivector (represents the axis of rotation)
|
|
||||||
// - angle of rotation
|
|
||||||
let twist_axis = (-basis.forward).normalized();
|
|
||||||
let twist_plane = Bivec3::from_normalized_axis(twist_axis);
|
|
||||||
|
|
||||||
// Calculate twist angle between the up vectors:
|
|
||||||
// u1 × uc ⋅ (-f)
|
|
||||||
// θ = atan2( ————————————— , u1 ⋅ uc )
|
|
||||||
// ‖u1 × uc‖
|
|
||||||
//
|
|
||||||
// Where:
|
|
||||||
// u1 = up vector after swing rotation
|
|
||||||
// uc = camera's current up vector
|
|
||||||
// f = forward vector (twist axis)
|
|
||||||
let theta = up_after_swing
|
|
||||||
.cross(self.up)
|
|
||||||
.dot(twist_axis)
|
|
||||||
.atan2(up_after_swing.dot(self.up));
|
|
||||||
|
|
||||||
let twist_rotor = Rotor3::from_angle_plane(theta, twist_plane);
|
|
||||||
|
|
||||||
self.rotor = (swing_rotor * twist_rotor).normalized();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
|
|
||||||
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
|
|
||||||
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
|
|
||||||
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
|
|
||||||
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
|
|
||||||
let r0 = row(0);
|
|
||||||
let r1 = row(1);
|
|
||||||
let r2 = row(2);
|
|
||||||
let r3 = row(3);
|
|
||||||
let mut planes = [
|
|
||||||
add(r3, r0),
|
|
||||||
sub(r3, r0),
|
|
||||||
add(r3, r1),
|
|
||||||
sub(r3, r1),
|
|
||||||
r2,
|
|
||||||
sub(r3, r2),
|
|
||||||
];
|
|
||||||
for (plane, p) in planes.iter_mut().enumerate() {
|
|
||||||
if !p.iter().all(|component| component.is_finite()) {
|
|
||||||
return Err(FrustumError::NonFinite { plane });
|
|
||||||
}
|
|
||||||
// Scale first: directly squaring very large/small coefficients can overflow or
|
|
||||||
// underflow even though the plane itself is normalizable.
|
|
||||||
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
|
|
||||||
if scale < f32::MIN_POSITIVE {
|
|
||||||
return Err(FrustumError::Degenerate { plane });
|
|
||||||
}
|
|
||||||
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
|
|
||||||
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
|
|
||||||
for v in p {
|
|
||||||
*v = (*v / scale) / length;
|
|
||||||
if !v.is_finite() {
|
|
||||||
return Err(FrustumError::NonFinite { plane });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(planes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn vec3_is_finite(value: Vec3) -> bool {
|
|
||||||
value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
|
|
||||||
let mut nonfinite = Camera::new(1.0).view_proj;
|
|
||||||
nonfinite[0][0] = f32::NAN;
|
|
||||||
assert!(matches!(
|
|
||||||
extract_frustum_planes(nonfinite),
|
|
||||||
Err(FrustumError::NonFinite { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
extract_frustum_planes([[0.0; 4]; 4]),
|
|
||||||
Err(FrustumError::Degenerate { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn frustum_extraction_normalizes_without_overflow() {
|
|
||||||
let mut matrix = Camera::new(1.0).view_proj;
|
|
||||||
for value in matrix.iter_mut().flatten() {
|
|
||||||
*value *= 1.0e20;
|
|
||||||
}
|
|
||||||
let planes = extract_frustum_planes(matrix).unwrap();
|
|
||||||
for plane in planes {
|
|
||||||
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
|
|
||||||
assert!((length - 1.0).abs() < 1.0e-5);
|
|
||||||
assert!(plane.iter().all(|value| value.is_finite()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_vec3_close(actual: Vec3, expected: Vec3, epsilon: f32) {
|
|
||||||
assert!(
|
|
||||||
(actual.x - expected.x).abs() <= epsilon,
|
|
||||||
"x: {actual:?} != {expected:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(actual.y - expected.y).abs() <= epsilon,
|
|
||||||
"y: {actual:?} != {expected:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(actual.z - expected.z).abs() <= epsilon,
|
|
||||||
"z: {actual:?} != {expected:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_camera_finite(camera: &Camera) {
|
|
||||||
assert!(vec3_is_finite(camera.position));
|
|
||||||
assert!(vec3_is_finite(camera.target));
|
|
||||||
assert!(camera.distance.is_finite());
|
|
||||||
assert!(camera
|
|
||||||
.view_proj
|
|
||||||
.iter()
|
|
||||||
.flatten()
|
|
||||||
.all(|component| component.is_finite()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zoom_is_multiplicative_and_preserves_target() {
|
|
||||||
let mut camera = Camera::new(1.0);
|
|
||||||
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
|
||||||
let initial_position = camera.position;
|
|
||||||
let initial_target = camera.target;
|
|
||||||
let initial_distance = camera.distance;
|
|
||||||
|
|
||||||
camera.zoom(-100.0);
|
|
||||||
assert!(camera.distance < initial_distance);
|
|
||||||
assert_eq!(camera.target, initial_target);
|
|
||||||
|
|
||||||
camera.zoom(100.0);
|
|
||||||
assert_vec3_close(camera.position, initial_position, 1e-4);
|
|
||||||
assert!((camera.distance - initial_distance).abs() <= 1e-4);
|
|
||||||
|
|
||||||
camera.zoom(100.0);
|
|
||||||
assert!(camera.distance > initial_distance);
|
|
||||||
assert_eq!(camera.target, initial_target);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zoom_clamps_and_remains_finite() {
|
|
||||||
let mut camera = Camera::new(1.0);
|
|
||||||
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
|
||||||
camera.zoom(f32::NEG_INFINITY);
|
|
||||||
assert!((camera.distance - 10.0).abs() <= 1e-5);
|
|
||||||
camera.zoom(-f32::MAX);
|
|
||||||
assert!((camera.distance - MIN_DISTANCE).abs() <= f32::EPSILON);
|
|
||||||
camera.zoom(f32::MAX);
|
|
||||||
assert!(camera.distance <= camera.z_far * 0.95);
|
|
||||||
assert_camera_finite(&camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pan_moves_eye_and_target_equally_at_target_plane_scale() {
|
|
||||||
let mut camera = Camera::new(1.0);
|
|
||||||
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
|
||||||
let initial_position = camera.position;
|
|
||||||
let initial_target = camera.target;
|
|
||||||
let initial_offset = initial_position - initial_target;
|
|
||||||
let units = 2.0 * 10.0 * (PI / 6.0).tan() / 1000.0;
|
|
||||||
|
|
||||||
camera.pan(20.0, 10.0, 1000.0);
|
|
||||||
|
|
||||||
let translation = Vec3::new(-20.0 * units, 10.0 * units, 0.0);
|
|
||||||
assert_vec3_close(camera.position, initial_position + translation, 1e-5);
|
|
||||||
assert_vec3_close(camera.target, initial_target + translation, 1e-5);
|
|
||||||
assert_vec3_close(camera.position - camera.target, initial_offset, 1e-5);
|
|
||||||
assert!((camera.distance - 10.0).abs() <= 1e-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pan_scale_is_proportional_to_distance() {
|
|
||||||
let mut near = Camera::new(1.0);
|
|
||||||
near.look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::zero());
|
|
||||||
let mut far = Camera::new(1.0);
|
|
||||||
far.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
|
|
||||||
|
|
||||||
near.pan(10.0, 0.0, 1000.0);
|
|
||||||
far.pan(10.0, 0.0, 1000.0);
|
|
||||||
|
|
||||||
assert!((far.target.mag() / near.target.mag() - 2.0).abs() <= 1e-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn orbit_preserves_target_and_distance() {
|
|
||||||
let mut camera = Camera::new(1.0);
|
|
||||||
camera.look_at(Vec3::new(2.0, 3.0, 10.0), Vec3::new(1.0, -1.0, 0.5));
|
|
||||||
let initial_target = camera.target;
|
|
||||||
let initial_distance = (camera.position - camera.target).mag();
|
|
||||||
|
|
||||||
camera.orbit(40.0, -25.0);
|
|
||||||
|
|
||||||
assert_eq!(camera.target, initial_target);
|
|
||||||
assert!(((camera.position - camera.target).mag() - initial_distance).abs() <= 1e-5);
|
|
||||||
assert!((camera.distance - initial_distance).abs() <= 1e-5);
|
|
||||||
assert!(camera.distance >= MIN_DISTANCE);
|
|
||||||
assert_camera_finite(&camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn controls_reject_invalid_input_and_recover_degenerate_look_at() {
|
|
||||||
let mut camera = Camera::new(1.0);
|
|
||||||
camera.look_at(Vec3::zero(), Vec3::zero());
|
|
||||||
assert!((camera.position - camera.target).mag() >= MIN_DISTANCE);
|
|
||||||
let position = camera.position;
|
|
||||||
let target = camera.target;
|
|
||||||
|
|
||||||
camera.orbit(f32::NAN, 1.0);
|
|
||||||
camera.zoom(f32::INFINITY);
|
|
||||||
camera.pan(f32::NAN, 1.0, 0.0);
|
|
||||||
assert_eq!(camera.position, position);
|
|
||||||
assert_eq!(camera.target, target);
|
|
||||||
|
|
||||||
camera.pan(1.0, 1.0, 0.0);
|
|
||||||
camera.orbit(4.0, -3.0);
|
|
||||||
camera.zoom(2.0);
|
|
||||||
assert_camera_finite(&camera);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,774 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use gltf::Gltf;
|
|
||||||
use ultraviolet::{Mat4, Vec3};
|
|
||||||
|
|
||||||
use crate::render_data::{
|
|
||||||
InstanceHandle, InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform,
|
|
||||||
RenderData, RenderDataError,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub enum AlphaMode {
|
|
||||||
#[default]
|
|
||||||
Opaque,
|
|
||||||
Mask,
|
|
||||||
Blend,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct TextureReference {
|
|
||||||
pub texture: usize,
|
|
||||||
pub tex_coord: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct Material {
|
|
||||||
pub key: MaterialKey,
|
|
||||||
pub base_color_factor: [f32; 4],
|
|
||||||
pub metallic_factor: f32,
|
|
||||||
pub roughness_factor: f32,
|
|
||||||
pub emissive_factor: [f32; 3],
|
|
||||||
/// Index of refraction for the dielectric Fresnel response.
|
|
||||||
pub ior: f32,
|
|
||||||
pub alpha_mode: AlphaMode,
|
|
||||||
pub alpha_cutoff: f32,
|
|
||||||
pub double_sided: bool,
|
|
||||||
pub base_color_texture: Option<TextureReference>,
|
|
||||||
pub metallic_roughness_texture: Option<TextureReference>,
|
|
||||||
pub normal_texture: Option<TextureReference>,
|
|
||||||
pub normal_scale: f32,
|
|
||||||
pub occlusion_texture: Option<TextureReference>,
|
|
||||||
pub occlusion_strength: f32,
|
|
||||||
pub emissive_texture: Option<TextureReference>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Material {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
key: MaterialKey::DEFAULT,
|
|
||||||
base_color_factor: [1.0; 4],
|
|
||||||
metallic_factor: 1.0,
|
|
||||||
roughness_factor: 1.0,
|
|
||||||
emissive_factor: [0.0; 3],
|
|
||||||
ior: 1.5,
|
|
||||||
alpha_mode: AlphaMode::Opaque,
|
|
||||||
alpha_cutoff: 0.5,
|
|
||||||
double_sided: false,
|
|
||||||
base_color_texture: None,
|
|
||||||
metallic_roughness_texture: None,
|
|
||||||
normal_texture: None,
|
|
||||||
normal_scale: 1.0,
|
|
||||||
occlusion_texture: None,
|
|
||||||
occlusion_strength: 1.0,
|
|
||||||
emissive_texture: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct TextureMetadata {
|
|
||||||
pub image: usize,
|
|
||||||
pub sampler: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct SamplerMetadata {
|
|
||||||
pub index: usize,
|
|
||||||
pub mag_filter: Option<String>,
|
|
||||||
pub min_filter: Option<String>,
|
|
||||||
pub wrap_s: String,
|
|
||||||
pub wrap_t: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ImageSource {
|
|
||||||
Uri(String),
|
|
||||||
BufferView(usize),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ImageMetadata {
|
|
||||||
pub index: usize,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub mime_type: Option<String>,
|
|
||||||
pub source: ImageSource,
|
|
||||||
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
|
|
||||||
/// upload images one at a time instead of retaining a decoded image batch.
|
|
||||||
pub encoded_data: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct InstalledScene {
|
|
||||||
pub meshes: Vec<MeshHandle>,
|
|
||||||
pub instances: Vec<InstanceHandle>,
|
|
||||||
pub bounds: Option<ModelBounds>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct ModelBounds {
|
|
||||||
pub min: [f32; 3],
|
|
||||||
pub max: [f32; 3],
|
|
||||||
}
|
|
||||||
impl ModelBounds {
|
|
||||||
fn include(&mut self, p: [f32; 3]) {
|
|
||||||
for i in 0..3 {
|
|
||||||
self.min[i] = self.min[i].min(p[i]);
|
|
||||||
self.max[i] = self.max[i].max(p[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
|
|
||||||
let first = *points.first()?;
|
|
||||||
if points.len() < 200 {
|
|
||||||
let mut bounds = ModelBounds {
|
|
||||||
min: first,
|
|
||||||
max: first,
|
|
||||||
};
|
|
||||||
for point in &points[1..] {
|
|
||||||
bounds.include(*point);
|
|
||||||
}
|
|
||||||
return Some(bounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
let trim = points.len() / 100;
|
|
||||||
let mut min = [0.0; 3];
|
|
||||||
let mut max = [0.0; 3];
|
|
||||||
for axis in 0..3 {
|
|
||||||
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
|
|
||||||
values.sort_by(f32::total_cmp);
|
|
||||||
min[axis] = values[trim];
|
|
||||||
max[axis] = values[values.len() - trim - 1];
|
|
||||||
}
|
|
||||||
Some(ModelBounds { min, max })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ImportError {
|
|
||||||
#[error("failed to decode bytes")]
|
|
||||||
GltfParse(#[from] gltf::Error),
|
|
||||||
#[error("unsupported or malformed primitive: {0}")]
|
|
||||||
InvalidPrimitive(String),
|
|
||||||
#[error("unsupported image source: {0}")]
|
|
||||||
UnsupportedImage(String),
|
|
||||||
#[error("invalid KHR_materials_ior value: {0}")]
|
|
||||||
InvalidIor(f32),
|
|
||||||
#[error("failed to install imported scene")]
|
|
||||||
Install(#[from] RenderDataError),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
|
|
||||||
let ior = value.unwrap_or(1.5);
|
|
||||||
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
|
|
||||||
Ok(ior)
|
|
||||||
} else {
|
|
||||||
Err(ImportError::InvalidIor(ior))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ImportedGeometry {
|
|
||||||
pub key: (usize, usize),
|
|
||||||
pub material: MaterialKey,
|
|
||||||
pub double_sided: bool,
|
|
||||||
pub positions: Vec<[f32; 3]>,
|
|
||||||
pub normals: Vec<[f32; 3]>,
|
|
||||||
pub tangents: Vec<[f32; 4]>,
|
|
||||||
pub uvs: Vec<[f32; 2]>,
|
|
||||||
pub indices: Vec<u32>,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ImportedOccurrence {
|
|
||||||
pub key: (usize, usize),
|
|
||||||
pub transform: ModelTransform,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct ImportedScene {
|
|
||||||
pub geometries: Vec<ImportedGeometry>,
|
|
||||||
pub occurrences: Vec<ImportedOccurrence>,
|
|
||||||
pub materials: Vec<Material>,
|
|
||||||
pub textures: Vec<TextureMetadata>,
|
|
||||||
pub samplers: Vec<SamplerMetadata>,
|
|
||||||
pub images: Vec<ImageMetadata>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
|
|
||||||
TextureReference {
|
|
||||||
texture: info.texture().index(),
|
|
||||||
tex_coord: info.tex_coord(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
|
|
||||||
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
||||||
if length > f32::EPSILON && length.is_finite() {
|
|
||||||
value.map(|x| x / length)
|
|
||||||
} else {
|
|
||||||
fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
|
||||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
|
||||||
[
|
|
||||||
a[1] * b[2] - a[2] * b[1],
|
|
||||||
a[2] * b[0] - a[0] * b[2],
|
|
||||||
a[0] * b[1] - a[1] * b[0],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
|
||||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
|
|
||||||
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
|
|
||||||
fn repair_geometry(
|
|
||||||
positions: Vec<[f32; 3]>,
|
|
||||||
normals: Option<Vec<[f32; 3]>>,
|
|
||||||
tangents: Option<Vec<[f32; 4]>>,
|
|
||||||
uvs: Vec<[f32; 2]>,
|
|
||||||
indices: Vec<u32>,
|
|
||||||
) -> Result<
|
|
||||||
(
|
|
||||||
Vec<[f32; 3]>,
|
|
||||||
Vec<[f32; 3]>,
|
|
||||||
Vec<[f32; 4]>,
|
|
||||||
Vec<[f32; 2]>,
|
|
||||||
Vec<u32>,
|
|
||||||
),
|
|
||||||
ImportError,
|
|
||||||
> {
|
|
||||||
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
|
|
||||||
return Err(ImportError::InvalidPrimitive(
|
|
||||||
"triangle indices are malformed".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
|
|
||||||
let tangents_valid = tangents
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|x| x.len() == positions.len());
|
|
||||||
if normals_valid && tangents_valid {
|
|
||||||
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out_p = Vec::with_capacity(indices.len());
|
|
||||||
let mut out_n = Vec::with_capacity(indices.len());
|
|
||||||
let mut out_t = Vec::with_capacity(indices.len());
|
|
||||||
let mut out_uv = Vec::with_capacity(indices.len());
|
|
||||||
for triangle in indices.chunks_exact(3) {
|
|
||||||
let ids = [
|
|
||||||
triangle[0] as usize,
|
|
||||||
triangle[1] as usize,
|
|
||||||
triangle[2] as usize,
|
|
||||||
];
|
|
||||||
let p = ids.map(|i| positions[i]);
|
|
||||||
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
|
|
||||||
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
|
|
||||||
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
|
|
||||||
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
|
|
||||||
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
|
|
||||||
let edge1 = sub(p[1], p[0]);
|
|
||||||
let edge2 = sub(p[2], p[0]);
|
|
||||||
let (raw_tangent, raw_bitangent) =
|
|
||||||
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
|
|
||||||
let r = determinant.recip();
|
|
||||||
(
|
|
||||||
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
|
|
||||||
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
([0.0; 3], [0.0; 3])
|
|
||||||
};
|
|
||||||
for corner in 0..3 {
|
|
||||||
let n = normals
|
|
||||||
.as_ref()
|
|
||||||
.filter(|_| normals_valid)
|
|
||||||
.map_or(face_normal, |x| x[ids[corner]]);
|
|
||||||
let n = normalize(n, face_normal);
|
|
||||||
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
|
|
||||||
let fallback_axis = if n[0].abs() < 0.9 {
|
|
||||||
[1.0, 0.0, 0.0]
|
|
||||||
} else {
|
|
||||||
[0.0, 1.0, 0.0]
|
|
||||||
};
|
|
||||||
let tangent3 = normalize(
|
|
||||||
projected,
|
|
||||||
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
|
|
||||||
);
|
|
||||||
let generated = [
|
|
||||||
tangent3[0],
|
|
||||||
tangent3[1],
|
|
||||||
tangent3[2],
|
|
||||||
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
|
|
||||||
-1.0
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
},
|
|
||||||
];
|
|
||||||
out_p.push(p[corner]);
|
|
||||||
out_n.push(n);
|
|
||||||
out_t.push(
|
|
||||||
tangents
|
|
||||||
.as_ref()
|
|
||||||
.filter(|_| tangents_valid)
|
|
||||||
.map_or(generated, |x| x[ids[corner]]),
|
|
||||||
);
|
|
||||||
out_uv.push(uv[corner]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let out_i = (0..u32::try_from(out_p.len())
|
|
||||||
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
|
|
||||||
.collect();
|
|
||||||
Ok((out_p, out_n, out_t, out_uv, out_i))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
|
|
||||||
decode_gltf_model(Gltf::from_slice(bytes)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
|
|
||||||
let model = Gltf::from_slice(&bytes)?;
|
|
||||||
drop(bytes);
|
|
||||||
decode_gltf_model(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
|
|
||||||
// Reject external images before buffer import can turn them into a generic
|
|
||||||
// import error (or attempt to interpret a data/external URI).
|
|
||||||
for image in model.images() {
|
|
||||||
if let gltf::image::Source::Uri { uri, .. } = image.source() {
|
|
||||||
return Err(ImportError::UnsupportedImage(format!(
|
|
||||||
"URI/external image '{uri}'"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let blob = model.blob.take();
|
|
||||||
let buffers = gltf::import_buffers(&model.document, None, blob)?;
|
|
||||||
let mut result = ImportedScene::default();
|
|
||||||
result.materials.push(Material::default());
|
|
||||||
for material in model.materials() {
|
|
||||||
let pbr = material.pbr_metallic_roughness();
|
|
||||||
let normal = material.normal_texture();
|
|
||||||
let normal_texture = normal.as_ref().map(|x| TextureReference {
|
|
||||||
texture: x.texture().index(),
|
|
||||||
tex_coord: x.tex_coord(),
|
|
||||||
});
|
|
||||||
let occlusion = material.occlusion_texture();
|
|
||||||
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
|
|
||||||
texture: x.texture().index(),
|
|
||||||
tex_coord: x.tex_coord(),
|
|
||||||
});
|
|
||||||
result.materials.push(Material {
|
|
||||||
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
|
|
||||||
base_color_factor: pbr.base_color_factor(),
|
|
||||||
metallic_factor: pbr.metallic_factor(),
|
|
||||||
roughness_factor: pbr.roughness_factor(),
|
|
||||||
emissive_factor: material.emissive_factor(),
|
|
||||||
ior: decode_ior(material.ior())?,
|
|
||||||
alpha_mode: match material.alpha_mode() {
|
|
||||||
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
|
|
||||||
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
|
|
||||||
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
|
|
||||||
},
|
|
||||||
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
|
|
||||||
double_sided: material.double_sided(),
|
|
||||||
base_color_texture: pbr.base_color_texture().map(texture_reference),
|
|
||||||
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
|
|
||||||
normal_texture,
|
|
||||||
normal_scale: normal.map_or(1.0, |x| x.scale()),
|
|
||||||
occlusion_texture,
|
|
||||||
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
|
|
||||||
emissive_texture: material.emissive_texture().map(texture_reference),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
result.textures = model
|
|
||||||
.textures()
|
|
||||||
.map(|x| TextureMetadata {
|
|
||||||
image: x.source().index(),
|
|
||||||
sampler: x.sampler().index(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
result.samplers = model
|
|
||||||
.samplers()
|
|
||||||
.map(|x| SamplerMetadata {
|
|
||||||
index: x.index().unwrap(),
|
|
||||||
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
|
|
||||||
min_filter: x.min_filter().map(|v| format!("{v:?}")),
|
|
||||||
wrap_s: format!("{:?}", x.wrap_s()),
|
|
||||||
wrap_t: format!("{:?}", x.wrap_t()),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
result.images = model
|
|
||||||
.images()
|
|
||||||
.map(|x| -> Result<_, ImportError> {
|
|
||||||
let (source, mime_type, encoded_data) = match x.source() {
|
|
||||||
gltf::image::Source::Uri { uri, .. } => {
|
|
||||||
return Err(ImportError::UnsupportedImage(format!(
|
|
||||||
"URI/external image '{uri}'"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
gltf::image::Source::View { view, mime_type } => {
|
|
||||||
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
|
|
||||||
ImportError::UnsupportedImage("image buffer is missing".into())
|
|
||||||
})?;
|
|
||||||
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
|
|
||||||
ImportError::UnsupportedImage("image bufferView overflows".into())
|
|
||||||
})?;
|
|
||||||
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
|
|
||||||
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
|
|
||||||
})?;
|
|
||||||
(
|
|
||||||
ImageSource::BufferView(view.index()),
|
|
||||||
Some(mime_type.to_owned()),
|
|
||||||
bytes.to_vec(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(ImageMetadata {
|
|
||||||
index: x.index(),
|
|
||||||
name: x.name().map(str::to_owned),
|
|
||||||
mime_type,
|
|
||||||
source,
|
|
||||||
encoded_data,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<_, _>>()?;
|
|
||||||
let mut seen = HashMap::new();
|
|
||||||
fn visit(
|
|
||||||
node: gltf::Node<'_>,
|
|
||||||
parent: Mat4,
|
|
||||||
buffers: &[gltf::buffer::Data],
|
|
||||||
result: &mut ImportedScene,
|
|
||||||
seen: &mut HashMap<(usize, usize), ()>,
|
|
||||||
) -> Result<(), ImportError> {
|
|
||||||
let world = parent * Mat4::from(node.transform().matrix());
|
|
||||||
if let Some(mesh) = node.mesh() {
|
|
||||||
for primitive in mesh.primitives() {
|
|
||||||
if primitive.mode() != gltf::mesh::Mode::Triangles {
|
|
||||||
return Err(ImportError::InvalidPrimitive(
|
|
||||||
"only triangle primitives are supported".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let key = (mesh.index(), primitive.index());
|
|
||||||
if seen.insert(key, ()).is_none() {
|
|
||||||
let reader = primitive
|
|
||||||
.reader(|buffer| buffers.get(buffer.index()).map(|data| data.0.as_slice()));
|
|
||||||
let Some(read_positions) = reader.read_positions() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let positions: Vec<_> = read_positions.collect();
|
|
||||||
let count = positions.len();
|
|
||||||
if count == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let normals = reader.read_normals().map(|x| x.collect());
|
|
||||||
let tangents = reader.read_tangents().map(|x| x.collect());
|
|
||||||
let mut uvs: Vec<_> = reader
|
|
||||||
.read_tex_coords(0)
|
|
||||||
.map(|x| x.into_f32().collect())
|
|
||||||
.unwrap_or_default();
|
|
||||||
uvs.resize(count, [0., 0.]);
|
|
||||||
uvs.truncate(count);
|
|
||||||
let indices: Vec<u32> = if let Some(indices) = reader.read_indices() {
|
|
||||||
indices.into_u32().collect()
|
|
||||||
} else {
|
|
||||||
let count = u32::try_from(count).map_err(|_| {
|
|
||||||
ImportError::InvalidPrimitive("vertex count exceeds u32".into())
|
|
||||||
})?;
|
|
||||||
(0..count).collect()
|
|
||||||
};
|
|
||||||
if indices.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (positions, normals, tangents, uvs, indices) =
|
|
||||||
repair_geometry(positions, normals, tangents, uvs, indices)?;
|
|
||||||
let primitive_material = primitive.material();
|
|
||||||
result.geometries.push(ImportedGeometry {
|
|
||||||
key,
|
|
||||||
material: primitive_material
|
|
||||||
.index()
|
|
||||||
.map_or(MaterialKey::DEFAULT, |index| {
|
|
||||||
MaterialKey::new(index as u32 + 1)
|
|
||||||
}),
|
|
||||||
double_sided: primitive_material.double_sided(),
|
|
||||||
positions,
|
|
||||||
normals,
|
|
||||||
tangents,
|
|
||||||
uvs,
|
|
||||||
indices,
|
|
||||||
});
|
|
||||||
} else if !result.geometries.iter().any(|geometry| geometry.key == key) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.occurrences.push(ImportedOccurrence {
|
|
||||||
key,
|
|
||||||
transform: world.into(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for child in node.children() {
|
|
||||||
visit(child, world, buffers, result, seen)?
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
for scene in model.scenes() {
|
|
||||||
for node in scene.nodes() {
|
|
||||||
visit(node, Mat4::identity(), &buffers, &mut result, &mut seen)?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn install_imported(
|
|
||||||
target: &mut RenderData,
|
|
||||||
imported: &ImportedScene,
|
|
||||||
) -> Result<InstalledScene, ImportError> {
|
|
||||||
let mut stage = target.replacement_stage()?;
|
|
||||||
let mut handles = HashMap::new();
|
|
||||||
let mut mesh_handles = Vec::with_capacity(imported.geometries.len());
|
|
||||||
let mut instance_handles = Vec::new();
|
|
||||||
let mut first = HashMap::new();
|
|
||||||
for occurrence in &imported.occurrences {
|
|
||||||
first.entry(occurrence.key).or_insert(occurrence.transform);
|
|
||||||
}
|
|
||||||
for geometry in &imported.geometries {
|
|
||||||
let transform = *first
|
|
||||||
.get(&geometry.key)
|
|
||||||
.ok_or_else(|| ImportError::InvalidPrimitive("geometry has no occurrence".into()))?;
|
|
||||||
let created = stage.create_mesh(MeshCreateInfo {
|
|
||||||
positions: &geometry.positions,
|
|
||||||
normals: &geometry.normals,
|
|
||||||
tangents: &geometry.tangents,
|
|
||||||
uvs: &geometry.uvs,
|
|
||||||
indices: &geometry.indices,
|
|
||||||
material: geometry.material,
|
|
||||||
default_instance_type: InstanceType {
|
|
||||||
words: [
|
|
||||||
1 | 4 | (geometry.double_sided as u32) * 8,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
default_transform: transform,
|
|
||||||
})?;
|
|
||||||
handles.insert(geometry.key, created.mesh);
|
|
||||||
mesh_handles.push(created.mesh);
|
|
||||||
instance_handles.push(created.default_instance);
|
|
||||||
}
|
|
||||||
let mut consumed = HashMap::new();
|
|
||||||
let mut bounds: Option<ModelBounds> = None;
|
|
||||||
let geometries: HashMap<_, _> = imported
|
|
||||||
.geometries
|
|
||||||
.iter()
|
|
||||||
.map(|geometry| (geometry.key, geometry))
|
|
||||||
.collect();
|
|
||||||
let mut focus_points = Vec::new();
|
|
||||||
for occurrence in &imported.occurrences {
|
|
||||||
let mesh = *handles
|
|
||||||
.get(&occurrence.key)
|
|
||||||
.ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?;
|
|
||||||
if consumed.insert(occurrence.key, ()).is_some() {
|
|
||||||
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
|
|
||||||
instance_handles.push(stage.create_instance(
|
|
||||||
mesh,
|
|
||||||
occurrence.transform,
|
|
||||||
instance_type,
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
let geometry = geometries
|
|
||||||
.get(&occurrence.key)
|
|
||||||
.expect("installed occurrence must have geometry");
|
|
||||||
let transform = Mat4::from(occurrence.transform);
|
|
||||||
focus_points.extend(geometry.positions.iter().map(|position| {
|
|
||||||
let point = transform.transform_point3(Vec3::from(*position));
|
|
||||||
[point.x, point.y, point.z]
|
|
||||||
}));
|
|
||||||
let local = stage.mesh(mesh).unwrap().local_aabb;
|
|
||||||
for x in [local.min[0], local.max[0]] {
|
|
||||||
for y in [local.min[1], local.max[1]] {
|
|
||||||
for z in [local.min[2], local.max[2]] {
|
|
||||||
let p = Mat4::from(occurrence.transform).transform_point3(Vec3::new(x, y, z));
|
|
||||||
let p = [p.x, p.y, p.z];
|
|
||||||
if let Some(b) = bounds.as_mut() {
|
|
||||||
b.include(p)
|
|
||||||
} else {
|
|
||||||
bounds = Some(ModelBounds { min: p, max: p })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bounds = focus_bounds(&focus_points).or(bounds);
|
|
||||||
target.replace_with(stage)?;
|
|
||||||
Ok(InstalledScene {
|
|
||||||
meshes: mesh_handles,
|
|
||||||
instances: instance_handles,
|
|
||||||
bounds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uri_images_are_rejected_explicitly() {
|
|
||||||
let json = br#"{
|
|
||||||
"asset":{"version":"2.0"},
|
|
||||||
"images":[{"uri":"external.png"}],
|
|
||||||
"scenes":[{"nodes":[]}],"scene":0
|
|
||||||
}"#;
|
|
||||||
let result = decode_gltf(json);
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
result,
|
|
||||||
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
|
|
||||||
),
|
|
||||||
"unexpected result: {result:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn owned_and_borrowed_decode_paths_remain_compatible() {
|
|
||||||
let json = br#"{
|
|
||||||
"asset":{"version":"2.0"},
|
|
||||||
"scenes":[{"nodes":[]}],"scene":0
|
|
||||||
}"#;
|
|
||||||
let borrowed = decode_gltf(json).unwrap();
|
|
||||||
let owned = decode_gltf_owned(json.to_vec()).unwrap();
|
|
||||||
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
|
|
||||||
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
|
|
||||||
assert_eq!(borrowed.materials.len(), owned.materials.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
|
|
||||||
let positions = vec![
|
|
||||||
[0.0, 0.0, 0.0],
|
|
||||||
[1.0, 0.0, 0.0],
|
|
||||||
[0.0, 1.0, 0.0],
|
|
||||||
[0.0, 0.0, 1.0],
|
|
||||||
];
|
|
||||||
let repaired = repair_geometry(
|
|
||||||
positions,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
vec![[0.0; 2]; 4],
|
|
||||||
vec![0, 1, 2, 0, 3, 1],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(repaired.0.len(), 6);
|
|
||||||
assert_eq!(repaired.4, (0..6).collect::<Vec<_>>());
|
|
||||||
assert_eq!(&repaired.1[..3], &[[0.0, 0.0, 1.0]; 3]);
|
|
||||||
assert_eq!(&repaired.1[3..], &[[0.0, 1.0, 0.0]; 3]);
|
|
||||||
assert!(repaired
|
|
||||||
.2
|
|
||||||
.iter()
|
|
||||||
.flatten()
|
|
||||||
.all(|component| component.is_finite()));
|
|
||||||
assert!(repaired.2.iter().all(|tangent| tangent[3].abs() == 1.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_tangents_split_opposite_handedness_and_supplied_values_survive() {
|
|
||||||
let positions = vec![
|
|
||||||
[0.0, 0.0, 0.0],
|
|
||||||
[1.0, 0.0, 0.0],
|
|
||||||
[0.0, 1.0, 0.0],
|
|
||||||
[-1.0, 0.0, 0.0],
|
|
||||||
];
|
|
||||||
let normals = vec![[0.0, 0.0, 1.0]; 4];
|
|
||||||
let generated = repair_geometry(
|
|
||||||
positions.clone(),
|
|
||||||
Some(normals.clone()),
|
|
||||||
None,
|
|
||||||
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
|
|
||||||
vec![0, 1, 2, 0, 2, 3],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_ne!(generated.2[0][3], generated.2[3][3]);
|
|
||||||
|
|
||||||
let supplied = vec![[0.25, 0.5, 0.75, -1.0]; 4];
|
|
||||||
let preserved = repair_geometry(
|
|
||||||
positions,
|
|
||||||
Some(normals),
|
|
||||||
Some(supplied.clone()),
|
|
||||||
vec![[0.0; 2]; 4],
|
|
||||||
vec![0, 1, 2],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(preserved.2, supplied);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn material_defaults_match_gltf_core_defaults() {
|
|
||||||
let material = Material::default();
|
|
||||||
assert_eq!(material.base_color_factor, [1.0; 4]);
|
|
||||||
assert_eq!(material.metallic_factor, 1.0);
|
|
||||||
assert_eq!(material.roughness_factor, 1.0);
|
|
||||||
assert_eq!(material.alpha_cutoff, 0.5);
|
|
||||||
assert_eq!(material.ior, 1.5);
|
|
||||||
assert_eq!(material.key, MaterialKey::DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn imports_khr_materials_ior() {
|
|
||||||
let json = br#"{
|
|
||||||
"asset":{"version":"2.0"},
|
|
||||||
"extensionsUsed":["KHR_materials_ior"],
|
|
||||||
"materials":[{"extensions":{"KHR_materials_ior":{"ior":1.33}}}],
|
|
||||||
"scenes":[{"nodes":[]}],"scene":0
|
|
||||||
}"#;
|
|
||||||
let imported = decode_gltf(json).unwrap();
|
|
||||||
assert_eq!(imported.materials[1].ior, 1.33);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ior_gate_accepts_default_physical_values_and_explicit_zero_sentinel() {
|
|
||||||
assert_eq!(decode_ior(None).unwrap(), 1.5);
|
|
||||||
assert_eq!(decode_ior(Some(1.0)).unwrap(), 1.0);
|
|
||||||
assert_eq!(decode_ior(Some(1.33)).unwrap(), 1.33);
|
|
||||||
assert_eq!(decode_ior(Some(0.0)).unwrap(), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ior_gate_rejects_nonphysical_and_nonfinite_values() {
|
|
||||||
for ior in [-1.0, 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
|
|
||||||
assert!(matches!(
|
|
||||||
decode_ior(Some(ior)),
|
|
||||||
Err(ImportError::InvalidIor(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_but_parseable_json_ior_is_rejected_before_packing() {
|
|
||||||
for ior in ["-1", "0.5"] {
|
|
||||||
let json = format!(
|
|
||||||
r#"{{"asset":{{"version":"2.0"}},"extensionsUsed":["KHR_materials_ior"],"materials":[{{"extensions":{{"KHR_materials_ior":{{"ior":{ior}}}}}}}],"scenes":[{{"nodes":[]}}],"scene":0}}"#
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
decode_gltf(json.as_bytes()),
|
|
||||||
Err(ImportError::InvalidIor(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-3
@@ -1,8 +1,5 @@
|
|||||||
pub mod app_setup;
|
pub mod app_setup;
|
||||||
pub mod camera;
|
|
||||||
pub mod command_ring;
|
pub mod command_ring;
|
||||||
pub mod gltf;
|
|
||||||
pub mod message;
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod render_data;
|
pub mod render_data;
|
||||||
pub mod render_graph;
|
pub mod render_graph;
|
||||||
@@ -10,6 +7,22 @@ pub mod renderer;
|
|||||||
pub mod shared_snapshot;
|
pub mod shared_snapshot;
|
||||||
pub mod shared_soa;
|
pub mod shared_soa;
|
||||||
|
|
||||||
|
/// Start the core renderer inside its owning worker.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn worker_main() -> u32 {
|
||||||
|
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
|
||||||
|
wasm_logger::init(wasm_logger::Config::default());
|
||||||
|
app_setup::worker_entrypoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return this worker's shared WebAssembly memory to messaging clients.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn worker_memory() -> wasm_bindgen::JsValue {
|
||||||
|
wasm_bindgen::memory()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); }
|
thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
use core::fmt;
|
|
||||||
use std::cell::BorrowMutError;
|
|
||||||
use std::sync::mpsc::TryRecvError;
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
|
|
||||||
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
|
|
||||||
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum CameraDrag {
|
|
||||||
Orbit,
|
|
||||||
Pan,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
|
|
||||||
if buttons & MIDDLE_BUTTON_MASK != 0 {
|
|
||||||
Some(CameraDrag::Orbit)
|
|
||||||
} else if buttons & RIGHT_BUTTON_MASK != 0 {
|
|
||||||
Some(CameraDrag::Pan)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
|
|
||||||
if !delta_y.is_finite() || !viewport_height.is_finite() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let delta = match delta_mode {
|
|
||||||
0 => delta_y,
|
|
||||||
1 => delta_y * 16.0,
|
|
||||||
2 => delta_y * viewport_height.max(1.0),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let delta = delta as f32;
|
|
||||||
delta.is_finite().then_some(delta)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum WindowEvent {
|
|
||||||
Resize(ResizeMessage),
|
|
||||||
PointerMove(MouseMessage),
|
|
||||||
PointerClick(MouseMessage),
|
|
||||||
PointerWheel(WheelMessage),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ResizeMessage {
|
|
||||||
pub scale_factor: f64,
|
|
||||||
pub width: f64,
|
|
||||||
pub height: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MouseMessage {
|
|
||||||
pub scale_factor: f64,
|
|
||||||
pub buttons: u16,
|
|
||||||
pub movement_x: f64,
|
|
||||||
pub movement_y: f64,
|
|
||||||
pub offset_x: f64,
|
|
||||||
pub offset_y: f64,
|
|
||||||
pub viewport_height: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MouseMessage {
|
|
||||||
pub fn from_evt(event: &web_sys::MouseEvent, viewport_height: f64) -> Self {
|
|
||||||
let window = web_sys::window().unwrap();
|
|
||||||
Self {
|
|
||||||
scale_factor: window.device_pixel_ratio(),
|
|
||||||
buttons: event.buttons(),
|
|
||||||
movement_x: event.movement_x() as f64,
|
|
||||||
movement_y: event.movement_y() as f64,
|
|
||||||
offset_x: event.offset_x() as f64,
|
|
||||||
offset_y: event.offset_y() as f64,
|
|
||||||
viewport_height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_pointer_evt(event: &web_sys::PointerEvent, viewport_height: f64) -> Self {
|
|
||||||
Self::from_evt(event.unchecked_ref(), viewport_height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct WheelMessage {
|
|
||||||
pub delta_y_pixels: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WheelMessage {
|
|
||||||
pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
|
|
||||||
normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
|
|
||||||
.map(|delta_y_pixels| Self { delta_y_pixels })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum DrainEventError {
|
|
||||||
BorrowError(BorrowMutError),
|
|
||||||
ChannelDisconnected,
|
|
||||||
ChannelEmpty,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for DrainEventError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
DrainEventError::BorrowError(err) => write!(f, "Failed to borrow renderer: {}", err),
|
|
||||||
DrainEventError::ChannelDisconnected => write!(f, "Event channel disconnected"),
|
|
||||||
DrainEventError::ChannelEmpty => write!(f, "Event channel empty"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for DrainEventError {}
|
|
||||||
|
|
||||||
impl From<TryRecvError> for DrainEventError {
|
|
||||||
fn from(err: TryRecvError) -> Self {
|
|
||||||
match err {
|
|
||||||
TryRecvError::Empty => DrainEventError::ChannelEmpty,
|
|
||||||
TryRecvError::Disconnected => DrainEventError::ChannelDisconnected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<BorrowMutError> for DrainEventError {
|
|
||||||
fn from(err: BorrowMutError) -> Self {
|
|
||||||
DrainEventError::BorrowError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wheel_delta_is_normalized_to_css_pixels() {
|
|
||||||
assert_eq!(normalize_wheel_delta(12.5, 0, 640.0), Some(12.5));
|
|
||||||
assert_eq!(normalize_wheel_delta(2.0, 1, 640.0), Some(32.0));
|
|
||||||
assert_eq!(normalize_wheel_delta(-1.0, 2, 640.0), Some(-640.0));
|
|
||||||
assert_eq!(normalize_wheel_delta(2.0, 2, 0.0), Some(2.0));
|
|
||||||
assert_eq!(normalize_wheel_delta(1.0, 3, 640.0), None);
|
|
||||||
assert_eq!(normalize_wheel_delta(f64::NAN, 0, 640.0), None);
|
|
||||||
assert_eq!(normalize_wheel_delta(f64::INFINITY, 0, 640.0), None);
|
|
||||||
assert_eq!(normalize_wheel_delta(1.0, 0, f64::NAN), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn camera_drag_prefers_orbit_when_both_buttons_are_down() {
|
|
||||||
assert_eq!(camera_drag(0), None);
|
|
||||||
assert_eq!(camera_drag(1), None);
|
|
||||||
assert_eq!(camera_drag(RIGHT_BUTTON_MASK), Some(CameraDrag::Pan));
|
|
||||||
assert_eq!(camera_drag(MIDDLE_BUTTON_MASK), Some(CameraDrag::Orbit));
|
|
||||||
assert_eq!(
|
|
||||||
camera_drag(RIGHT_BUTTON_MASK | MIDDLE_BUTTON_MASK),
|
|
||||||
Some(CameraDrag::Orbit)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// The level editor's render worker owns its only WASM and WebGPU runtime.
|
// The render worker owns its only WASM and WebGPU runtime.
|
||||||
import initWasm, {
|
import initWasm, {
|
||||||
clear_payloads,
|
clear_payloads,
|
||||||
discard_payload,
|
discard_payload,
|
||||||
@@ -6,7 +6,7 @@ import initWasm, {
|
|||||||
worker_main,
|
worker_main,
|
||||||
worker_memory,
|
worker_memory,
|
||||||
worker_window_event,
|
worker_window_event,
|
||||||
} from "/level-editor/pkg/level_editor.js";
|
} from "/renderer/pkg/renderer.js";
|
||||||
|
|
||||||
function listenerReady() {
|
function listenerReady() {
|
||||||
if (state !== "waiting-listener") return;
|
if (state !== "waiting-listener") return;
|
||||||
@@ -30,7 +30,7 @@ addEventListener("message", async (event) => {
|
|||||||
}
|
}
|
||||||
if (state !== "uninitialized") return;
|
if (state !== "uninitialized") return;
|
||||||
state = "initializing";
|
state = "initializing";
|
||||||
const { canvas, profile } = message;
|
const { canvas } = message;
|
||||||
|
|
||||||
// The renderer worker exclusively owns the one WASM instance. Other threads
|
// The renderer worker exclusively owns the one WASM instance. Other threads
|
||||||
// receive only its shared memory and mutate the published SAB layouts.
|
// receive only its shared memory and mutate the published SAB layouts.
|
||||||
@@ -43,7 +43,7 @@ addEventListener("message", async (event) => {
|
|||||||
state = "waiting-listener";
|
state = "waiting-listener";
|
||||||
pending.push({ type: "canvas", canvas });
|
pending.push({ type: "canvas", canvas });
|
||||||
try {
|
try {
|
||||||
const ringPtr = worker_main(profile);
|
const ringPtr = worker_main();
|
||||||
postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr });
|
postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr });
|
||||||
setTimeout(listenerReady, 0);
|
setTimeout(listenerReady, 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::command_ring::CommandRing;
|
use crate::command_ring::CommandRing;
|
||||||
use crate::message::WindowEvent;
|
use crate::renderer::ResizeMessage;
|
||||||
use log::info;
|
use log::info;
|
||||||
use std::sync::mpsc::Receiver;
|
use std::sync::mpsc::Receiver;
|
||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
@@ -7,18 +7,12 @@ use wasm_bindgen::{prelude::*, JsValue};
|
|||||||
use wasm_bindgen_futures::JsFuture;
|
use wasm_bindgen_futures::JsFuture;
|
||||||
use web_sys::MessageEvent;
|
use web_sys::MessageEvent;
|
||||||
|
|
||||||
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
|
pub async fn run_render_loop(events_chan: Receiver<ResizeMessage>, ring: &'static CommandRing) {
|
||||||
events_chan: Receiver<WindowEvent>,
|
|
||||||
ring: &'static CommandRing,
|
|
||||||
profile: bool,
|
|
||||||
) {
|
|
||||||
use crate::renderer::Renderer;
|
use crate::renderer::Renderer;
|
||||||
|
|
||||||
let canvas = wait_for_canvas_transfer().await;
|
let canvas = wait_for_canvas_transfer().await;
|
||||||
|
|
||||||
let renderer = Rc::new(RefCell::new(
|
let renderer = Rc::new(RefCell::new(Renderer::new(canvas, events_chan).await));
|
||||||
Renderer::<T>::new(canvas, events_chan, profile).await,
|
|
||||||
));
|
|
||||||
renderer.borrow_mut().command_ring = Some(ring);
|
renderer.borrow_mut().command_ring = Some(ring);
|
||||||
Renderer::run_render_loop(renderer);
|
Renderer::run_render_loop(renderer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
use ultraviolet::{projection, Mat4, Vec3};
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::renderer::frame_data::UniformResource;
|
||||||
|
|
||||||
|
/// A camera matrix cannot produce a safe, meaningful frustum.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum FrustumError {
|
||||||
|
#[error("frustum plane {plane} contains a non-finite component")]
|
||||||
|
NonFinite { plane: usize },
|
||||||
|
#[error("frustum plane {plane} has a near-degenerate normal")]
|
||||||
|
Degenerate { plane: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_DISTANCE: f32 = 0.1;
|
||||||
|
|
||||||
|
/// SIMD-width shared camera row: eye, target, up, then projection parameters.
|
||||||
|
pub type SharedCameraState = [f32; 16];
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct Camera {
|
||||||
|
// Hot data - cached computed matrix (64 bytes, 1 cache line)
|
||||||
|
pub view_proj: [[f32; 4]; 4],
|
||||||
|
|
||||||
|
// Warm data - frequently accessed vectors (36 bytes)
|
||||||
|
position: Vec3,
|
||||||
|
target: Vec3,
|
||||||
|
up: Vec3,
|
||||||
|
|
||||||
|
// Cold data - projection parameters (16 bytes)
|
||||||
|
fov: f32,
|
||||||
|
aspect_ratio: f32,
|
||||||
|
z_near: f32,
|
||||||
|
z_far: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
|
||||||
|
pub struct CameraUniform {
|
||||||
|
view_proj: [[f32; 4]; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Camera {
|
||||||
|
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
|
||||||
|
extract_frustum_planes(self.view_proj)
|
||||||
|
}
|
||||||
|
pub fn new(aspect_ratio: f32) -> Self {
|
||||||
|
let mut camera = Camera {
|
||||||
|
view_proj: [[0.0; 4]; 4],
|
||||||
|
position: Vec3::new(0.0, 0.5, 3.0),
|
||||||
|
target: Vec3::new(0.0, 0.0, 0.0),
|
||||||
|
up: Vec3::unit_y(),
|
||||||
|
fov: PI / 3.0,
|
||||||
|
aspect_ratio,
|
||||||
|
z_near: 0.1,
|
||||||
|
z_far: 100000.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
camera.compute_view_proj_mat();
|
||||||
|
|
||||||
|
camera
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_view_proj_mat(&mut self) {
|
||||||
|
let view = Mat4::look_at(self.position, self.target, self.up);
|
||||||
|
let proj = projection::rh_yup::perspective_wgpu_dx(
|
||||||
|
self.fov,
|
||||||
|
self.aspect_ratio,
|
||||||
|
self.z_near,
|
||||||
|
self.z_far,
|
||||||
|
);
|
||||||
|
self.view_proj = (proj * view).into();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position(&self) -> Vec3 {
|
||||||
|
self.position
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
|
||||||
|
self.aspect_ratio = aspect_ratio;
|
||||||
|
self.compute_view_proj_mat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the canonical 64-byte shared row.
|
||||||
|
pub fn shared_state(&self) -> SharedCameraState {
|
||||||
|
[
|
||||||
|
self.position.x,
|
||||||
|
self.position.y,
|
||||||
|
self.position.z,
|
||||||
|
1.0,
|
||||||
|
self.target.x,
|
||||||
|
self.target.y,
|
||||||
|
self.target.z,
|
||||||
|
1.0,
|
||||||
|
self.up.x,
|
||||||
|
self.up.y,
|
||||||
|
self.up.z,
|
||||||
|
0.0,
|
||||||
|
self.fov,
|
||||||
|
self.aspect_ratio,
|
||||||
|
self.z_near,
|
||||||
|
self.z_far,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a complete shared row, rejecting malformed external writes.
|
||||||
|
pub fn apply_shared_state(&mut self, state: SharedCameraState) -> bool {
|
||||||
|
if !state.iter().all(|value| value.is_finite())
|
||||||
|
|| !(0.0..PI).contains(&state[12])
|
||||||
|
|| state[13] <= 0.0
|
||||||
|
|| state[14] <= 0.0
|
||||||
|
|| state[15] <= state[14]
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let position = Vec3::new(state[0], state[1], state[2]);
|
||||||
|
let target = Vec3::new(state[4], state[5], state[6]);
|
||||||
|
let up = Vec3::new(state[8], state[9], state[10]);
|
||||||
|
let forward = target - position;
|
||||||
|
if forward.mag_sq() < MIN_DISTANCE * MIN_DISTANCE
|
||||||
|
|| up.mag_sq() <= f32::EPSILON
|
||||||
|
|| forward.cross(up).mag_sq() <= f32::EPSILON
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.position = position;
|
||||||
|
self.target = target;
|
||||||
|
self.up = up.normalized();
|
||||||
|
self.fov = state[12];
|
||||||
|
self.aspect_ratio = state[13];
|
||||||
|
self.z_near = state[14];
|
||||||
|
self.z_far = state[15];
|
||||||
|
self.compute_view_proj_mat();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
|
||||||
|
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: "camera uniform buffer".into(),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
contents: bytemuck::cast_slice(&[self.view_proj]),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("Camera bind group layout"),
|
||||||
|
entries: &[wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("Camera bind group"),
|
||||||
|
layout: &bind_group_layout,
|
||||||
|
entries: &[wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: buffer.as_entire_binding(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
UniformResource {
|
||||||
|
buffer,
|
||||||
|
bind_group,
|
||||||
|
bind_group_layout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
|
||||||
|
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
|
||||||
|
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
|
||||||
|
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
|
||||||
|
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
|
||||||
|
let r0 = row(0);
|
||||||
|
let r1 = row(1);
|
||||||
|
let r2 = row(2);
|
||||||
|
let r3 = row(3);
|
||||||
|
let mut planes = [
|
||||||
|
add(r3, r0),
|
||||||
|
sub(r3, r0),
|
||||||
|
add(r3, r1),
|
||||||
|
sub(r3, r1),
|
||||||
|
r2,
|
||||||
|
sub(r3, r2),
|
||||||
|
];
|
||||||
|
for (plane, p) in planes.iter_mut().enumerate() {
|
||||||
|
if !p.iter().all(|component| component.is_finite()) {
|
||||||
|
return Err(FrustumError::NonFinite { plane });
|
||||||
|
}
|
||||||
|
// Scale first: directly squaring very large/small coefficients can overflow or
|
||||||
|
// underflow even though the plane itself is normalizable.
|
||||||
|
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
|
||||||
|
if scale < f32::MIN_POSITIVE {
|
||||||
|
return Err(FrustumError::Degenerate { plane });
|
||||||
|
}
|
||||||
|
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
|
||||||
|
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
|
||||||
|
for v in p {
|
||||||
|
*v = (*v / scale) / length;
|
||||||
|
if !v.is_finite() {
|
||||||
|
return Err(FrustumError::NonFinite { plane });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(planes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
|
||||||
|
let mut nonfinite = Camera::new(1.0).view_proj;
|
||||||
|
nonfinite[0][0] = f32::NAN;
|
||||||
|
assert!(matches!(
|
||||||
|
extract_frustum_planes(nonfinite),
|
||||||
|
Err(FrustumError::NonFinite { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
extract_frustum_planes([[0.0; 4]; 4]),
|
||||||
|
Err(FrustumError::Degenerate { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frustum_extraction_normalizes_without_overflow() {
|
||||||
|
let mut matrix = Camera::new(1.0).view_proj;
|
||||||
|
for value in matrix.iter_mut().flatten() {
|
||||||
|
*value *= 1.0e20;
|
||||||
|
}
|
||||||
|
let planes = extract_frustum_planes(matrix).unwrap();
|
||||||
|
for plane in planes {
|
||||||
|
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
|
||||||
|
assert!((length - 1.0).abs() < 1.0e-5);
|
||||||
|
assert!(plane.iter().all(|value| value.is_finite()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_state_round_trips_and_rejects_invalid_projection() {
|
||||||
|
let mut camera = Camera::new(1.0);
|
||||||
|
let state = [
|
||||||
|
2.0,
|
||||||
|
3.0,
|
||||||
|
10.0,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
-1.0,
|
||||||
|
0.5,
|
||||||
|
1.0,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
PI / 4.0,
|
||||||
|
16.0 / 9.0,
|
||||||
|
0.25,
|
||||||
|
500.0,
|
||||||
|
];
|
||||||
|
assert!(camera.apply_shared_state(state));
|
||||||
|
assert_eq!(camera.shared_state(), state);
|
||||||
|
assert!(camera
|
||||||
|
.view_proj
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.all(|component| component.is_finite()));
|
||||||
|
|
||||||
|
let mut invalid = state;
|
||||||
|
invalid[15] = invalid[14];
|
||||||
|
assert!(!camera.apply_shared_state(invalid));
|
||||||
|
assert_eq!(camera.shared_state(), state);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub(crate) mod camera;
|
||||||
mod handle;
|
mod handle;
|
||||||
mod range_allocator;
|
mod range_allocator;
|
||||||
|
pub(crate) mod upload;
|
||||||
|
|
||||||
pub use handle::{InstanceHandle, MeshHandle};
|
pub use handle::{InstanceHandle, MeshHandle};
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ pub const IDENTITY_NORMAL_MATRIX: NormalMatrix =
|
|||||||
pub struct MaterialKey(u32);
|
pub struct MaterialKey(u32);
|
||||||
|
|
||||||
impl MaterialKey {
|
impl MaterialKey {
|
||||||
/// The glTF/default material.
|
/// The default material.
|
||||||
pub const DEFAULT: Self = Self(0);
|
pub const DEFAULT: Self = Self(0);
|
||||||
|
|
||||||
pub const fn new(value: u32) -> Self {
|
pub const fn new(value: u32) -> Self {
|
||||||
@@ -428,6 +430,9 @@ impl RenderData {
|
|||||||
|
|
||||||
/// Creates an empty transactional successor whose handles cannot alias this data.
|
/// Creates an empty transactional successor whose handles cannot alias this data.
|
||||||
pub fn replacement_stage(&self) -> Result<ReplacementStage, RenderDataError> {
|
pub fn replacement_stage(&self) -> Result<ReplacementStage, RenderDataError> {
|
||||||
|
// Preflight the only fallible operation left at commit time. With the stage
|
||||||
|
// prepared synchronously, lineage and handle generations cannot drift.
|
||||||
|
self.next_revision()?;
|
||||||
let capacities = self.capacities();
|
let capacities = self.capacities();
|
||||||
let mut stage = Self::new(RenderDataConfig {
|
let mut stage = Self::new(RenderDataConfig {
|
||||||
initial_vertices: capacities.vertices,
|
initial_vertices: capacities.vertices,
|
||||||
|
|||||||
@@ -0,0 +1,734 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use ultraviolet::{Mat4, Vec3};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, RenderData,
|
||||||
|
RenderDataError, ReplacementStage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAGIC: u32 = u32::from_le_bytes(*b"YRDP");
|
||||||
|
const VERSION: u32 = 1;
|
||||||
|
const HEADER_BYTES: usize = 16;
|
||||||
|
const BASE_COLOR_TEXTURE: u32 = 1 << 0;
|
||||||
|
const METALLIC_ROUGHNESS_TEXTURE: u32 = 1 << 1;
|
||||||
|
const NORMAL_TEXTURE: u32 = 1 << 2;
|
||||||
|
const OCCLUSION_TEXTURE: u32 = 1 << 3;
|
||||||
|
const EMISSIVE_TEXTURE: u32 = 1 << 4;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AlphaMode {
|
||||||
|
#[default]
|
||||||
|
Opaque,
|
||||||
|
Mask,
|
||||||
|
Blend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct TextureReference {
|
||||||
|
pub texture: usize,
|
||||||
|
pub tex_coord: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Material {
|
||||||
|
pub key: MaterialKey,
|
||||||
|
pub base_color_factor: [f32; 4],
|
||||||
|
pub metallic_factor: f32,
|
||||||
|
pub roughness_factor: f32,
|
||||||
|
pub emissive_factor: [f32; 3],
|
||||||
|
pub ior: f32,
|
||||||
|
pub alpha_mode: AlphaMode,
|
||||||
|
pub alpha_cutoff: f32,
|
||||||
|
pub double_sided: bool,
|
||||||
|
pub base_color_texture: Option<TextureReference>,
|
||||||
|
pub metallic_roughness_texture: Option<TextureReference>,
|
||||||
|
pub normal_texture: Option<TextureReference>,
|
||||||
|
pub normal_scale: f32,
|
||||||
|
pub occlusion_texture: Option<TextureReference>,
|
||||||
|
pub occlusion_strength: f32,
|
||||||
|
pub emissive_texture: Option<TextureReference>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Material {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
key: MaterialKey::DEFAULT,
|
||||||
|
base_color_factor: [1.0; 4],
|
||||||
|
metallic_factor: 1.0,
|
||||||
|
roughness_factor: 1.0,
|
||||||
|
emissive_factor: [0.0; 3],
|
||||||
|
ior: 1.5,
|
||||||
|
alpha_mode: AlphaMode::Opaque,
|
||||||
|
alpha_cutoff: 0.5,
|
||||||
|
double_sided: false,
|
||||||
|
base_color_texture: None,
|
||||||
|
metallic_roughness_texture: None,
|
||||||
|
normal_texture: None,
|
||||||
|
normal_scale: 1.0,
|
||||||
|
occlusion_texture: None,
|
||||||
|
occlusion_strength: 1.0,
|
||||||
|
emissive_texture: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SIMD-aligned material row shared with external render-data writers and the GPU.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct MaterialState {
|
||||||
|
pub base_color_factor: [f32; 4],
|
||||||
|
pub emissive_factor: [f32; 4],
|
||||||
|
pub surface_factors: [f32; 4],
|
||||||
|
pub alpha_optics: [f32; 4],
|
||||||
|
pub flags: [u32; 4],
|
||||||
|
pub uv_sets: [u32; 4],
|
||||||
|
pub debug_extras: [u32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
|
||||||
|
reference
|
||||||
|
.filter(|value| value.tex_coord == 0)
|
||||||
|
.map_or(0, |_| bit)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Material> for MaterialState {
|
||||||
|
fn from(value: &Material) -> Self {
|
||||||
|
Self {
|
||||||
|
base_color_factor: value.base_color_factor,
|
||||||
|
emissive_factor: [
|
||||||
|
value.emissive_factor[0],
|
||||||
|
value.emissive_factor[1],
|
||||||
|
value.emissive_factor[2],
|
||||||
|
0.0,
|
||||||
|
],
|
||||||
|
surface_factors: [
|
||||||
|
value.metallic_factor,
|
||||||
|
value.roughness_factor,
|
||||||
|
value.normal_scale,
|
||||||
|
value.occlusion_strength,
|
||||||
|
],
|
||||||
|
alpha_optics: [
|
||||||
|
match value.alpha_mode {
|
||||||
|
AlphaMode::Opaque => 0.0,
|
||||||
|
AlphaMode::Mask => 1.0,
|
||||||
|
AlphaMode::Blend => 2.0,
|
||||||
|
},
|
||||||
|
value.alpha_cutoff,
|
||||||
|
value.ior,
|
||||||
|
if value.ior == 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
flags: [
|
||||||
|
enabled(value.base_color_texture, BASE_COLOR_TEXTURE)
|
||||||
|
| enabled(value.metallic_roughness_texture, METALLIC_ROUGHNESS_TEXTURE)
|
||||||
|
| enabled(value.normal_texture, NORMAL_TEXTURE)
|
||||||
|
| enabled(value.occlusion_texture, OCCLUSION_TEXTURE)
|
||||||
|
| enabled(value.emissive_texture, EMISSIVE_TEXTURE),
|
||||||
|
u32::from(value.double_sided),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
uv_sets: [
|
||||||
|
value.base_color_texture.map_or(0, |value| value.tex_coord),
|
||||||
|
value
|
||||||
|
.metallic_roughness_texture
|
||||||
|
.map_or(0, |value| value.tex_coord),
|
||||||
|
value.normal_texture.map_or(0, |value| value.tex_coord),
|
||||||
|
value.occlusion_texture.map_or(0, |value| value.tex_coord),
|
||||||
|
],
|
||||||
|
debug_extras: [
|
||||||
|
value.emissive_texture.map_or(0, |value| value.tex_coord),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterialState {
|
||||||
|
pub const LANES: u32 = 28;
|
||||||
|
|
||||||
|
pub fn words(self) -> [u32; Self::LANES as usize] {
|
||||||
|
bytemuck::cast(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_words(words: [u32; Self::LANES as usize]) -> Self {
|
||||||
|
bytemuck::cast(words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _: [(); 112] = [(); std::mem::size_of::<MaterialState>()];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FilterMode {
|
||||||
|
Nearest,
|
||||||
|
#[default]
|
||||||
|
Linear,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AddressMode {
|
||||||
|
ClampToEdge,
|
||||||
|
MirrorRepeat,
|
||||||
|
#[default]
|
||||||
|
Repeat,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct TextureMetadata {
|
||||||
|
pub image: usize,
|
||||||
|
pub sampler: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct SamplerMetadata {
|
||||||
|
#[serde(default)]
|
||||||
|
pub mag_filter: FilterMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub min_filter: FilterMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mipmap_filter: FilterMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address_u: AddressMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address_v: AddressMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ImageMetadata {
|
||||||
|
pub mime_type: String,
|
||||||
|
pub encoded_data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UploadedGeometry {
|
||||||
|
pub id: u32,
|
||||||
|
pub material: MaterialKey,
|
||||||
|
pub instance_type: InstanceType,
|
||||||
|
pub positions: Vec<[f32; 3]>,
|
||||||
|
pub normals: Vec<[f32; 3]>,
|
||||||
|
pub tangents: Vec<[f32; 4]>,
|
||||||
|
pub uvs: Vec<[f32; 2]>,
|
||||||
|
pub indices: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UploadedOccurrence {
|
||||||
|
pub geometry: u32,
|
||||||
|
pub transform: ModelTransform,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct RenderDataUpload {
|
||||||
|
pub geometries: Vec<UploadedGeometry>,
|
||||||
|
pub occurrences: Vec<UploadedOccurrence>,
|
||||||
|
pub materials: Vec<Material>,
|
||||||
|
pub textures: Vec<TextureMetadata>,
|
||||||
|
pub samplers: Vec<SamplerMetadata>,
|
||||||
|
pub images: Vec<ImageMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct ModelBounds {
|
||||||
|
pub min: [f32; 3],
|
||||||
|
pub max: [f32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelBounds {
|
||||||
|
fn include(&mut self, point: [f32; 3]) {
|
||||||
|
for axis in 0..3 {
|
||||||
|
self.min[axis] = self.min[axis].min(point[axis]);
|
||||||
|
self.max[axis] = self.max[axis].max(point[axis]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct InstalledRenderData {
|
||||||
|
pub meshes: Vec<MeshHandle>,
|
||||||
|
pub bounds: Option<ModelBounds>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PreparedRenderData {
|
||||||
|
pub stage: ReplacementStage,
|
||||||
|
pub installed: InstalledRenderData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum RenderDataUploadError {
|
||||||
|
#[error("render-data packet is malformed: {0}")]
|
||||||
|
Malformed(&'static str),
|
||||||
|
#[error("render-data packet metadata is invalid: {0}")]
|
||||||
|
Metadata(#[from] serde_json::Error),
|
||||||
|
#[error("render-data packet contains invalid geometry: {0}")]
|
||||||
|
InvalidGeometry(&'static str),
|
||||||
|
#[error("render-data packet contains invalid material data")]
|
||||||
|
InvalidMaterial,
|
||||||
|
#[error("failed to install uploaded render data")]
|
||||||
|
Install(#[from] RenderDataError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct DataSlice {
|
||||||
|
offset: u32,
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct ByteSlice {
|
||||||
|
offset: u32,
|
||||||
|
byte_length: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct GeometryMetadata {
|
||||||
|
id: u32,
|
||||||
|
material: u32,
|
||||||
|
instance_type: [u32; 16],
|
||||||
|
positions: DataSlice,
|
||||||
|
normals: DataSlice,
|
||||||
|
tangents: DataSlice,
|
||||||
|
uvs: DataSlice,
|
||||||
|
indices: DataSlice,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct OccurrenceMetadata {
|
||||||
|
geometry: u32,
|
||||||
|
transform: [f32; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct MaterialMetadata {
|
||||||
|
key: u32,
|
||||||
|
base_color_factor: [f32; 4],
|
||||||
|
metallic_factor: f32,
|
||||||
|
roughness_factor: f32,
|
||||||
|
emissive_factor: [f32; 3],
|
||||||
|
ior: f32,
|
||||||
|
alpha_mode: AlphaMode,
|
||||||
|
alpha_cutoff: f32,
|
||||||
|
double_sided: bool,
|
||||||
|
base_color_texture: Option<TextureReference>,
|
||||||
|
metallic_roughness_texture: Option<TextureReference>,
|
||||||
|
normal_texture: Option<TextureReference>,
|
||||||
|
normal_scale: f32,
|
||||||
|
occlusion_texture: Option<TextureReference>,
|
||||||
|
occlusion_strength: f32,
|
||||||
|
emissive_texture: Option<TextureReference>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<MaterialMetadata> for Material {
|
||||||
|
type Error = RenderDataUploadError;
|
||||||
|
|
||||||
|
fn try_from(value: MaterialMetadata) -> Result<Self, Self::Error> {
|
||||||
|
let finite = value
|
||||||
|
.base_color_factor
|
||||||
|
.iter()
|
||||||
|
.chain(value.emissive_factor.iter())
|
||||||
|
.chain([
|
||||||
|
&value.metallic_factor,
|
||||||
|
&value.roughness_factor,
|
||||||
|
&value.ior,
|
||||||
|
&value.alpha_cutoff,
|
||||||
|
&value.normal_scale,
|
||||||
|
&value.occlusion_strength,
|
||||||
|
])
|
||||||
|
.all(|component| component.is_finite());
|
||||||
|
if !finite || (value.ior != 0.0 && value.ior < 1.0) {
|
||||||
|
return Err(RenderDataUploadError::InvalidMaterial);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
key: MaterialKey::new(value.key),
|
||||||
|
base_color_factor: value.base_color_factor,
|
||||||
|
metallic_factor: value.metallic_factor,
|
||||||
|
roughness_factor: value.roughness_factor,
|
||||||
|
emissive_factor: value.emissive_factor,
|
||||||
|
ior: value.ior,
|
||||||
|
alpha_mode: value.alpha_mode,
|
||||||
|
alpha_cutoff: value.alpha_cutoff,
|
||||||
|
double_sided: value.double_sided,
|
||||||
|
base_color_texture: value.base_color_texture,
|
||||||
|
metallic_roughness_texture: value.metallic_roughness_texture,
|
||||||
|
normal_texture: value.normal_texture,
|
||||||
|
normal_scale: value.normal_scale,
|
||||||
|
occlusion_texture: value.occlusion_texture,
|
||||||
|
occlusion_strength: value.occlusion_strength,
|
||||||
|
emissive_texture: value.emissive_texture,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct ImageMetadataPacket {
|
||||||
|
mime_type: String,
|
||||||
|
data: ByteSlice,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct PacketMetadata {
|
||||||
|
#[serde(default)]
|
||||||
|
geometries: Vec<GeometryMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
occurrences: Vec<OccurrenceMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
materials: Vec<MaterialMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
textures: Vec<TextureMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
samplers: Vec<SamplerMetadata>,
|
||||||
|
#[serde(default)]
|
||||||
|
images: Vec<ImageMetadataPacket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word(bytes: &[u8], offset: usize) -> Result<u32, RenderDataUploadError> {
|
||||||
|
let raw: [u8; 4] = bytes
|
||||||
|
.get(offset..offset + 4)
|
||||||
|
.ok_or(RenderDataUploadError::Malformed("header is truncated"))?
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
Ok(u32::from_le_bytes(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range(payload: &[u8], offset: u32, byte_length: usize) -> Result<&[u8], RenderDataUploadError> {
|
||||||
|
let start = usize::try_from(offset)
|
||||||
|
.map_err(|_| RenderDataUploadError::Malformed("data offset exceeds usize"))?;
|
||||||
|
let end = start
|
||||||
|
.checked_add(byte_length)
|
||||||
|
.ok_or(RenderDataUploadError::Malformed("data range overflows"))?;
|
||||||
|
payload
|
||||||
|
.get(start..end)
|
||||||
|
.ok_or(RenderDataUploadError::Malformed(
|
||||||
|
"data range is out of bounds",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f32_vectors<const N: usize>(
|
||||||
|
payload: &[u8],
|
||||||
|
slice: DataSlice,
|
||||||
|
) -> Result<Vec<[f32; N]>, RenderDataUploadError> {
|
||||||
|
if slice.offset % 4 != 0 {
|
||||||
|
return Err(RenderDataUploadError::Malformed("float data is unaligned"));
|
||||||
|
}
|
||||||
|
let count = usize::try_from(slice.count)
|
||||||
|
.map_err(|_| RenderDataUploadError::Malformed("element count exceeds usize"))?;
|
||||||
|
let byte_length = count
|
||||||
|
.checked_mul(N)
|
||||||
|
.and_then(|value| value.checked_mul(4))
|
||||||
|
.ok_or(RenderDataUploadError::Malformed(
|
||||||
|
"float data size overflows",
|
||||||
|
))?;
|
||||||
|
let bytes = range(payload, slice.offset, byte_length)?;
|
||||||
|
Ok(bytes
|
||||||
|
.chunks_exact(N * 4)
|
||||||
|
.map(|chunk| {
|
||||||
|
std::array::from_fn(|lane| {
|
||||||
|
f32::from_le_bytes(chunk[lane * 4..lane * 4 + 4].try_into().unwrap())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u32_values(payload: &[u8], slice: DataSlice) -> Result<Vec<u32>, RenderDataUploadError> {
|
||||||
|
if slice.offset % 4 != 0 {
|
||||||
|
return Err(RenderDataUploadError::Malformed(
|
||||||
|
"integer data is unaligned",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let byte_length = usize::try_from(slice.count)
|
||||||
|
.ok()
|
||||||
|
.and_then(|count| count.checked_mul(4))
|
||||||
|
.ok_or(RenderDataUploadError::Malformed(
|
||||||
|
"integer data size overflows",
|
||||||
|
))?;
|
||||||
|
Ok(range(payload, slice.offset, byte_length)?
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the generic binary render-data packet accepted by core.
|
||||||
|
pub fn decode_render_data_packet(bytes: &[u8]) -> Result<RenderDataUpload, RenderDataUploadError> {
|
||||||
|
if bytes.len() < HEADER_BYTES || word(bytes, 0)? != MAGIC {
|
||||||
|
return Err(RenderDataUploadError::Malformed("magic is invalid"));
|
||||||
|
}
|
||||||
|
if word(bytes, 4)? != VERSION {
|
||||||
|
return Err(RenderDataUploadError::Malformed("version is unsupported"));
|
||||||
|
}
|
||||||
|
let metadata_len = usize::try_from(word(bytes, 8)?)
|
||||||
|
.map_err(|_| RenderDataUploadError::Malformed("metadata size exceeds usize"))?;
|
||||||
|
let payload_len = usize::try_from(word(bytes, 12)?)
|
||||||
|
.map_err(|_| RenderDataUploadError::Malformed("payload size exceeds usize"))?;
|
||||||
|
let metadata_end = HEADER_BYTES
|
||||||
|
.checked_add(metadata_len)
|
||||||
|
.ok_or(RenderDataUploadError::Malformed("metadata size overflows"))?;
|
||||||
|
let payload_start = metadata_end.checked_add(3).map(|value| value & !3).ok_or(
|
||||||
|
RenderDataUploadError::Malformed("payload alignment overflows"),
|
||||||
|
)?;
|
||||||
|
let packet_end = payload_start
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.ok_or(RenderDataUploadError::Malformed("packet size overflows"))?;
|
||||||
|
if packet_end != bytes.len() {
|
||||||
|
return Err(RenderDataUploadError::Malformed(
|
||||||
|
"packet length is not exact",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let metadata: PacketMetadata = serde_json::from_slice(
|
||||||
|
bytes
|
||||||
|
.get(HEADER_BYTES..metadata_end)
|
||||||
|
.ok_or(RenderDataUploadError::Malformed("metadata is truncated"))?,
|
||||||
|
)?;
|
||||||
|
let payload = &bytes[payload_start..packet_end];
|
||||||
|
|
||||||
|
let mut geometry_ids = HashSet::new();
|
||||||
|
let geometries = metadata
|
||||||
|
.geometries
|
||||||
|
.into_iter()
|
||||||
|
.map(|geometry| {
|
||||||
|
if !geometry_ids.insert(geometry.id) {
|
||||||
|
return Err(RenderDataUploadError::InvalidGeometry(
|
||||||
|
"geometry id is duplicated",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(UploadedGeometry {
|
||||||
|
id: geometry.id,
|
||||||
|
material: MaterialKey::new(geometry.material),
|
||||||
|
instance_type: InstanceType {
|
||||||
|
words: geometry.instance_type,
|
||||||
|
},
|
||||||
|
positions: f32_vectors(payload, geometry.positions)?,
|
||||||
|
normals: f32_vectors(payload, geometry.normals)?,
|
||||||
|
tangents: f32_vectors(payload, geometry.tangents)?,
|
||||||
|
uvs: f32_vectors(payload, geometry.uvs)?,
|
||||||
|
indices: u32_values(payload, geometry.indices)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let occurrences = metadata
|
||||||
|
.occurrences
|
||||||
|
.into_iter()
|
||||||
|
.map(|occurrence| UploadedOccurrence {
|
||||||
|
geometry: occurrence.geometry,
|
||||||
|
transform: std::array::from_fn(|column| {
|
||||||
|
std::array::from_fn(|row| occurrence.transform[column * 4 + row])
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let materials = metadata
|
||||||
|
.materials
|
||||||
|
.into_iter()
|
||||||
|
.map(Material::try_from)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let images = metadata
|
||||||
|
.images
|
||||||
|
.into_iter()
|
||||||
|
.map(|image| -> Result<_, RenderDataUploadError> {
|
||||||
|
let byte_length = usize::try_from(image.data.byte_length)
|
||||||
|
.map_err(|_| RenderDataUploadError::Malformed("image size exceeds usize"))?;
|
||||||
|
Ok(ImageMetadata {
|
||||||
|
mime_type: image.mime_type,
|
||||||
|
encoded_data: range(payload, image.data.offset, byte_length)?.to_vec(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(RenderDataUpload {
|
||||||
|
geometries,
|
||||||
|
occurrences,
|
||||||
|
materials,
|
||||||
|
textures: metadata.textures,
|
||||||
|
samplers: metadata.samplers,
|
||||||
|
images,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
|
||||||
|
let first = *points.first()?;
|
||||||
|
if points.len() < 200 {
|
||||||
|
let mut bounds = ModelBounds {
|
||||||
|
min: first,
|
||||||
|
max: first,
|
||||||
|
};
|
||||||
|
for point in &points[1..] {
|
||||||
|
bounds.include(*point);
|
||||||
|
}
|
||||||
|
return Some(bounds);
|
||||||
|
}
|
||||||
|
let trim = points.len() / 100;
|
||||||
|
let mut min = [0.0; 3];
|
||||||
|
let mut max = [0.0; 3];
|
||||||
|
for axis in 0..3 {
|
||||||
|
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
|
||||||
|
values.sort_by(f32::total_cmp);
|
||||||
|
min[axis] = values[trim];
|
||||||
|
max[axis] = values[values.len() - trim - 1];
|
||||||
|
}
|
||||||
|
Some(ModelBounds { min, max })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare a complete CPU-side replacement without changing live render data.
|
||||||
|
pub fn prepare_render_data(
|
||||||
|
target: &RenderData,
|
||||||
|
upload: &RenderDataUpload,
|
||||||
|
) -> Result<PreparedRenderData, RenderDataUploadError> {
|
||||||
|
let mut stage = target.replacement_stage()?;
|
||||||
|
let mut handles = HashMap::new();
|
||||||
|
let mut mesh_handles = Vec::with_capacity(upload.geometries.len());
|
||||||
|
let mut first = HashMap::new();
|
||||||
|
for occurrence in &upload.occurrences {
|
||||||
|
first
|
||||||
|
.entry(occurrence.geometry)
|
||||||
|
.or_insert(occurrence.transform);
|
||||||
|
}
|
||||||
|
for geometry in &upload.geometries {
|
||||||
|
let transform = *first
|
||||||
|
.get(&geometry.id)
|
||||||
|
.ok_or(RenderDataUploadError::InvalidGeometry(
|
||||||
|
"geometry has no occurrence",
|
||||||
|
))?;
|
||||||
|
let created = stage.create_mesh(MeshCreateInfo {
|
||||||
|
positions: &geometry.positions,
|
||||||
|
normals: &geometry.normals,
|
||||||
|
tangents: &geometry.tangents,
|
||||||
|
uvs: &geometry.uvs,
|
||||||
|
indices: &geometry.indices,
|
||||||
|
material: geometry.material,
|
||||||
|
default_instance_type: geometry.instance_type,
|
||||||
|
default_transform: transform,
|
||||||
|
})?;
|
||||||
|
handles.insert(geometry.id, created.mesh);
|
||||||
|
mesh_handles.push(created.mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
let geometries: HashMap<_, _> = upload
|
||||||
|
.geometries
|
||||||
|
.iter()
|
||||||
|
.map(|geometry| (geometry.id, geometry))
|
||||||
|
.collect();
|
||||||
|
let mut consumed = HashSet::new();
|
||||||
|
let mut focus_points = Vec::new();
|
||||||
|
let mut bounds: Option<ModelBounds> = None;
|
||||||
|
for occurrence in &upload.occurrences {
|
||||||
|
let mesh =
|
||||||
|
*handles
|
||||||
|
.get(&occurrence.geometry)
|
||||||
|
.ok_or(RenderDataUploadError::InvalidGeometry(
|
||||||
|
"occurrence has no geometry",
|
||||||
|
))?;
|
||||||
|
if !consumed.insert(occurrence.geometry) {
|
||||||
|
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
|
||||||
|
stage.create_instance(mesh, occurrence.transform, instance_type)?;
|
||||||
|
}
|
||||||
|
let geometry = geometries[&occurrence.geometry];
|
||||||
|
let transform = Mat4::from(occurrence.transform);
|
||||||
|
focus_points.extend(geometry.positions.iter().map(|position| {
|
||||||
|
let point = transform.transform_point3(Vec3::from(*position));
|
||||||
|
[point.x, point.y, point.z]
|
||||||
|
}));
|
||||||
|
let local = stage.mesh(mesh).unwrap().local_aabb;
|
||||||
|
for x in [local.min[0], local.max[0]] {
|
||||||
|
for y in [local.min[1], local.max[1]] {
|
||||||
|
for z in [local.min[2], local.max[2]] {
|
||||||
|
let point = transform.transform_point3(Vec3::new(x, y, z));
|
||||||
|
let point = [point.x, point.y, point.z];
|
||||||
|
if let Some(existing) = bounds.as_mut() {
|
||||||
|
existing.include(point);
|
||||||
|
} else {
|
||||||
|
bounds = Some(ModelBounds {
|
||||||
|
min: point,
|
||||||
|
max: point,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bounds = focus_bounds(&focus_points).or(bounds);
|
||||||
|
Ok(PreparedRenderData {
|
||||||
|
stage,
|
||||||
|
installed: InstalledRenderData {
|
||||||
|
meshes: mesh_handles,
|
||||||
|
bounds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn packet(metadata: serde_json::Value, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let metadata = serde_json::to_vec(&metadata).unwrap();
|
||||||
|
let payload_offset = (HEADER_BYTES + metadata.len() + 3) & !3;
|
||||||
|
let mut packet = vec![0; payload_offset + payload.len()];
|
||||||
|
packet[0..4].copy_from_slice(&MAGIC.to_le_bytes());
|
||||||
|
packet[4..8].copy_from_slice(&VERSION.to_le_bytes());
|
||||||
|
packet[8..12].copy_from_slice(&(metadata.len() as u32).to_le_bytes());
|
||||||
|
packet[12..16].copy_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||||
|
packet[HEADER_BYTES..HEADER_BYTES + metadata.len()].copy_from_slice(&metadata);
|
||||||
|
packet[payload_offset..].copy_from_slice(payload);
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_packet_decodes_typed_streams_without_format_knowledge() {
|
||||||
|
let floats: Vec<f32> = [
|
||||||
|
0., 0., 0., 1., 0., 0., 0., 1., 0., // positions
|
||||||
|
0., 0., 1., 0., 0., 1., 0., 0., 1., // normals
|
||||||
|
1., 0., 0., 1., 1., 0., 0., 1., 1., 0., 0., 1., // tangents
|
||||||
|
0., 0., 1., 0., 0., 1., // uvs
|
||||||
|
]
|
||||||
|
.into();
|
||||||
|
let mut payload = bytemuck::cast_slice(&floats).to_vec();
|
||||||
|
payload.extend_from_slice(bytemuck::cast_slice(&[0u32, 1, 2]));
|
||||||
|
let upload = decode_render_data_packet(&packet(
|
||||||
|
serde_json::json!({
|
||||||
|
"geometries":[{
|
||||||
|
"id":7,"material":0,"instanceType":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||||
|
"positions":{"offset":0,"count":3},
|
||||||
|
"normals":{"offset":36,"count":3},
|
||||||
|
"tangents":{"offset":72,"count":3},
|
||||||
|
"uvs":{"offset":120,"count":3},
|
||||||
|
"indices":{"offset":144,"count":3}
|
||||||
|
}],
|
||||||
|
"occurrences":[{"geometry":7,"transform":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}],
|
||||||
|
"materials":[],"textures":[],"samplers":[],"images":[]
|
||||||
|
}),
|
||||||
|
&payload,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(upload.geometries[0].positions.len(), 3);
|
||||||
|
assert_eq!(upload.geometries[0].indices, [0, 1, 2]);
|
||||||
|
assert_eq!(upload.occurrences[0].geometry, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packet_length_and_ranges_are_exact() {
|
||||||
|
let mut invalid = packet(serde_json::json!({}), &[]);
|
||||||
|
invalid.push(0);
|
||||||
|
assert!(matches!(
|
||||||
|
decode_render_data_packet(&invalid),
|
||||||
|
Err(RenderDataUploadError::Malformed(
|
||||||
|
"packet length is not exact"
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -322,10 +322,12 @@ pub fn parse(bytes: &[u8]) -> Result<Graph, GraphError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn push_string(out: &mut String, value: &str) {
|
fn push_string(out: &mut String, value: &str) {
|
||||||
out.push_str(&serde_json::to_string(value).expect("strings always serialize"));
|
out.push_str(&serde_json::to_string(value).expect("strings always serialize"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn push_json(out: &mut String, value: &Value) {
|
fn push_json(out: &mut String, value: &Value) {
|
||||||
match value {
|
match value {
|
||||||
Value::Null => out.push_str("null"),
|
Value::Null => out.push_str("null"),
|
||||||
@@ -357,6 +359,7 @@ fn push_json(out: &mut String, value: &Value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serializes an internal graph for fixtures and cross-language conformance tests.
|
/// Serializes an internal graph for fixtures and cross-language conformance tests.
|
||||||
|
#[cfg(test)]
|
||||||
pub fn serialize(graph: &Graph) -> String {
|
pub fn serialize(graph: &Graph) -> String {
|
||||||
let mut out = format!("(yawn-graph {AST_VERSION}\n (id ");
|
let mut out = format!("(yawn-graph {AST_VERSION}\n (id ");
|
||||||
push_string(&mut out, &graph.graph_id);
|
push_string(&mut out, &graph.graph_id);
|
||||||
@@ -425,12 +428,14 @@ pub(crate) fn validate_pipeline_declarations(graph: &Graph) -> Result<(), GraphE
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !super::contract(name).is_some_and(|contract| {
|
if super::contract(name).is_some_and(|contract| {
|
||||||
contract.is_raster_draw() || contract.fullscreen_policy.is_some() || name == "frame_out"
|
!contract.is_raster_draw()
|
||||||
|
&& contract.fullscreen_policy.is_none()
|
||||||
|
&& name != "frame_out"
|
||||||
}) {
|
}) {
|
||||||
return Err(GraphError::new(
|
return Err(GraphError::new(
|
||||||
"GRAPH_EXECUTION_UNSUPPORTED",
|
"GRAPH_EXECUTION_UNSUPPORTED",
|
||||||
format!("authored render pipeline '{name}' has no render executor"),
|
format!("authored render pipeline '{name}' conflicts with a core executor"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
shader_bytes = shader_bytes.saturating_add(shader.len());
|
shader_bytes = shader_bytes.saturating_add(shader.len());
|
||||||
|
|||||||
@@ -460,7 +460,11 @@ fn normalize_texture(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
|
fn decode(
|
||||||
|
node: &Node,
|
||||||
|
i: usize,
|
||||||
|
pipelines: &PipelineDeclarations,
|
||||||
|
) -> Result<NormalizedParameters, GraphError> {
|
||||||
let base = format!("nodes[{i}].parameters");
|
let base = format!("nodes[{i}].parameters");
|
||||||
let invalid =
|
let invalid =
|
||||||
|e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone());
|
|e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone());
|
||||||
@@ -733,7 +737,7 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
|
|||||||
descriptor: normalize_texture(p.texture, &base)?,
|
descriptor: normalize_texture(p.texture, &base)?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
key if contract(key).is_some_and(|contract| contract.is_raster_draw()) => {
|
key if contract_for(key, pipelines).is_some_and(|contract| contract.is_raster_draw()) => {
|
||||||
let p: RasterParameters =
|
let p: RasterParameters =
|
||||||
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
|
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
|
||||||
if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) {
|
if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) {
|
||||||
@@ -758,10 +762,10 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
|
|||||||
predicate_default: p.predicate_default,
|
predicate_default: p.predicate_default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
key if contract(key)
|
key if contract_for(key, pipelines)
|
||||||
.is_some_and(|contract| contract.execution == ExecutionClass::Expression) =>
|
.is_some_and(|contract| contract.execution == ExecutionClass::Expression) =>
|
||||||
{
|
{
|
||||||
let contract = contract(key).unwrap();
|
let contract = contract_for(key, pipelines).unwrap();
|
||||||
let object = node.parameters.as_object().ok_or_else(|| {
|
let object = node.parameters.as_object().ok_or_else(|| {
|
||||||
error(
|
error(
|
||||||
"GRAPH_PARAMETERS_INVALID",
|
"GRAPH_PARAMETERS_INVALID",
|
||||||
@@ -909,7 +913,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, n)| {
|
.map(|(i, n)| {
|
||||||
contract(&n.executor.key).ok_or_else(|| {
|
contract_for(&n.executor.key, &graph.pipelines).ok_or_else(|| {
|
||||||
error(
|
error(
|
||||||
"GRAPH_UNKNOWN_EXECUTOR",
|
"GRAPH_UNKNOWN_EXECUTOR",
|
||||||
"unknown executor",
|
"unknown executor",
|
||||||
@@ -931,7 +935,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
|
|||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, n)| decode(n, i))
|
.map(|(i, n)| decode(n, i, &graph.pipelines))
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
if graph
|
if graph
|
||||||
.nodes
|
.nodes
|
||||||
|
|||||||
@@ -202,17 +202,6 @@ pub static CONTRACTS: &[Contract] = &[
|
|||||||
c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
|
c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
|
||||||
c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None),
|
c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None),
|
||||||
c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
|
c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
|
||||||
c!("ground_plane", 2, Render, RASTER_I, RASTER_O, false, None),
|
|
||||||
c!("gltf_standard", 2, Render, RASTER_I, RASTER_O, false, None),
|
|
||||||
c!(
|
|
||||||
"gltf_standard_double_sided",
|
|
||||||
2,
|
|
||||||
Render,
|
|
||||||
RASTER_I,
|
|
||||||
RASTER_O,
|
|
||||||
false,
|
|
||||||
None
|
|
||||||
),
|
|
||||||
c!(
|
c!(
|
||||||
"and",
|
"and",
|
||||||
2,
|
2,
|
||||||
@@ -443,3 +432,27 @@ pub static CONTRACTS: &[Contract] = &[
|
|||||||
pub fn contract(key: &str) -> Option<&'static Contract> {
|
pub fn contract(key: &str) -> Option<&'static Contract> {
|
||||||
CONTRACTS.iter().find(|c| c.key == key)
|
CONTRACTS.iter().find(|c| c.key == key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static AUTHORED_RENDER_PIPELINE: Contract = c!(
|
||||||
|
"authored_render_pipeline",
|
||||||
|
2,
|
||||||
|
Render,
|
||||||
|
RASTER_I,
|
||||||
|
RASTER_O,
|
||||||
|
false,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Resolve static core executors or a render pipeline declared by this graph.
|
||||||
|
pub fn contract_for(
|
||||||
|
key: &str,
|
||||||
|
pipelines: &crate::render_graph::PipelineDeclarations,
|
||||||
|
) -> Option<&'static Contract> {
|
||||||
|
contract(key).or_else(|| {
|
||||||
|
pipelines
|
||||||
|
.render
|
||||||
|
.iter()
|
||||||
|
.any(|pipeline| pipeline.name == key)
|
||||||
|
.then_some(&AUTHORED_RENDER_PIPELINE)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ mod registry;
|
|||||||
mod runtime;
|
mod runtime;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
|
||||||
pub use ast::{parse as parse_ast, serialize as serialize_ast};
|
|
||||||
pub use compiler::{compile, parse_and_compile};
|
pub use compiler::{compile, parse_and_compile};
|
||||||
pub use contracts::*;
|
pub use contracts::*;
|
||||||
pub use expression::*;
|
pub use expression::*;
|
||||||
|
|||||||
@@ -366,8 +366,8 @@ fn invalid(message: impl Into<String>, path: impl Into<String>) -> GraphError {
|
|||||||
error("GRAPH_RUNTIME_PLAN_INVALID", message, path)
|
error("GRAPH_RUNTIME_PLAN_INVALID", message, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execution_supported(key: &str) -> bool {
|
fn execution_supported(graph: &CompiledGraph, key: &str) -> bool {
|
||||||
contract(key).is_some_and(|contract| {
|
contract_for(key, &graph.pipelines).is_some_and(|contract| {
|
||||||
contract.fullscreen_policy.is_some() || contract.is_raster_draw() || key == "frame_out"
|
contract.fullscreen_policy.is_some() || contract.is_raster_draw() || key == "frame_out"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -742,7 +742,7 @@ fn validate_pipeline_resolve(
|
|||||||
.filter(|access| matches!(access.mode, AccessMode::ColorResolve { .. }))
|
.filter(|access| matches!(access.mode, AccessMode::ColorResolve { .. }))
|
||||||
.count()
|
.count()
|
||||||
== 1;
|
== 1;
|
||||||
if !contract(&producer.executor.key).is_some_and(Contract::is_raster_draw)
|
if !contract_for(&producer.executor.key, &graph.pipelines).is_some_and(Contract::is_raster_draw)
|
||||||
|| producer.original_node_index != *producer_node_index
|
|| producer.original_node_index != *producer_node_index
|
||||||
|| !exact_output
|
|| !exact_output
|
||||||
|| !matches!(&source.origin,
|
|| !matches!(&source.origin,
|
||||||
@@ -787,7 +787,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (i, execution) in graph.executions.iter().enumerate() {
|
for (i, execution) in graph.executions.iter().enumerate() {
|
||||||
if !execution_supported(&execution.executor.key) {
|
if !execution_supported(graph, &execution.executor.key) {
|
||||||
return Err(error(
|
return Err(error(
|
||||||
"GRAPH_EXECUTION_UNSUPPORTED",
|
"GRAPH_EXECUTION_UNSUPPORTED",
|
||||||
"unsupported execution",
|
"unsupported execution",
|
||||||
@@ -926,7 +926,8 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
for (i, execution) in graph.executions.iter().enumerate() {
|
for (i, execution) in graph.executions.iter().enumerate() {
|
||||||
let contract = contract(&execution.executor.key).expect("supported executor has contract");
|
let contract = contract_for(&execution.executor.key, &graph.pipelines)
|
||||||
|
.expect("supported executor has contract");
|
||||||
if execution.executor.version != contract.version {
|
if execution.executor.version != contract.version {
|
||||||
return Err(invalid(
|
return Err(invalid(
|
||||||
"executor version does not match its contract",
|
"executor version does not match its contract",
|
||||||
@@ -1011,7 +1012,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let producer_execution = &graph.executions[producer as usize];
|
let producer_execution = &graph.executions[producer as usize];
|
||||||
let input_contract = contract(&execution.executor.key)
|
let input_contract = contract_for(&execution.executor.key, &graph.pipelines)
|
||||||
.expect("supported executor has contract")
|
.expect("supported executor has contract")
|
||||||
.inputs
|
.inputs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1125,7 +1126,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
|
|||||||
.count()
|
.count()
|
||||||
== 1
|
== 1
|
||||||
})
|
})
|
||||||
&& contract(&owner_executions[0].executor.key)
|
&& contract_for(&owner_executions[0].executor.key, &graph.pipelines)
|
||||||
.is_some_and(Contract::is_raster_draw)
|
.is_some_and(Contract::is_raster_draw)
|
||||||
&& owner_executions[0].executor.version == 2
|
&& owner_executions[0].executor.version == 2
|
||||||
&& graph
|
&& graph
|
||||||
@@ -1135,7 +1136,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
|
|||||||
.filter(|input| input.resource == source)
|
.filter(|input| input.resource == source)
|
||||||
.count()
|
.count()
|
||||||
== 1
|
== 1
|
||||||
&& contract(&owner_executions[0].executor.key)
|
&& contract_for(&owner_executions[0].executor.key, &graph.pipelines)
|
||||||
.and_then(|contract| contract.inputs.get(*input_ordinal as usize))
|
.and_then(|contract| contract.inputs.get(*input_ordinal as usize))
|
||||||
.is_some_and(|input| {
|
.is_some_and(|input| {
|
||||||
input.name == *socket
|
input.name == *socket
|
||||||
@@ -1365,7 +1366,7 @@ fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError>
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, e)| {
|
.filter_map(|(i, e)| {
|
||||||
contract(&e.executor.key)
|
contract_for(&e.executor.key, &graph.pipelines)
|
||||||
.is_some_and(Contract::is_raster_draw)
|
.is_some_and(Contract::is_raster_draw)
|
||||||
.then_some(i as u32)
|
.then_some(i as u32)
|
||||||
})
|
})
|
||||||
@@ -1693,8 +1694,8 @@ pub fn prepare_runtime_plan(
|
|||||||
for (i, execution) in graph.executions.iter().enumerate() {
|
for (i, execution) in graph.executions.iter().enumerate() {
|
||||||
let path = format!("executions[{i}]");
|
let path = format!("executions[{i}]");
|
||||||
match execution.executor.key.as_str() {
|
match execution.executor.key.as_str() {
|
||||||
key if contract(key).is_some_and(Contract::is_raster_draw) => {}
|
key if contract_for(key, &graph.pipelines).is_some_and(Contract::is_raster_draw) => {}
|
||||||
_ if contract(&execution.executor.key)
|
_ if contract_for(&execution.executor.key, &graph.pipelines)
|
||||||
.is_some_and(|contract| contract.fullscreen_policy.is_some()) => {}
|
.is_some_and(|contract| contract.fullscreen_policy.is_some()) => {}
|
||||||
"frame_out" => {
|
"frame_out" => {
|
||||||
if frame_out_index.replace(i).is_some() {
|
if frame_out_index.replace(i).is_some() {
|
||||||
@@ -1728,7 +1729,9 @@ pub fn prepare_runtime_plan(
|
|||||||
validate_instance_traversal(graph)?;
|
validate_instance_traversal(graph)?;
|
||||||
|
|
||||||
for (i, execution) in graph.executions.iter().enumerate() {
|
for (i, execution) in graph.executions.iter().enumerate() {
|
||||||
if !contract(&execution.executor.key).is_some_and(Contract::is_raster_draw) {
|
if !contract_for(&execution.executor.key, &graph.pipelines)
|
||||||
|
.is_some_and(Contract::is_raster_draw)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let NormalizedParameters::Raster {
|
let NormalizedParameters::Raster {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ pub struct RenderPipelineDeclaration {
|
|||||||
pub fragment_entry: String,
|
pub fragment_entry: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub double_sided: bool,
|
pub double_sided: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub material: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A binding-free compute pass dispatched before the graph's render passes.
|
/// A binding-free compute pass dispatched before the graph's render passes.
|
||||||
|
|||||||
@@ -42,8 +42,17 @@ fn node(id: &str, key: &str, version: u32, parameters: Value, inputs: Value) ->
|
|||||||
"parameters": parameters, "inputs": inputs })
|
"parameters": parameters, "inputs": inputs })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_pipeline_declarations() -> Value {
|
||||||
|
json!({"render":[
|
||||||
|
{"name":"unlit","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":false,"material":false},
|
||||||
|
{"name":"material","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":false,"material":true},
|
||||||
|
{"name":"material_double_sided","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":true,"material":true}
|
||||||
|
],"compute":[]})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn full_cull_graph() -> Value {
|
pub(crate) fn full_cull_graph() -> Value {
|
||||||
json!({ "schemaVersion": 3, "graphId": "typed", "revision": 1, "nodes": [
|
json!({ "schemaVersion": 3, "graphId": "typed", "revision": 1,
|
||||||
|
"pipelines": render_pipeline_declarations(), "nodes": [
|
||||||
texture("color", "rgba16_float"), texture("depth", "depth32_float"),
|
texture("color", "rgba16_float"), texture("depth", "depth32_float"),
|
||||||
node("mesh", "mesh", 2, json!({}), json!({})),
|
node("mesh", "mesh", 2, json!({}), json!({})),
|
||||||
node("words", "separate_u32x16", 1, json!({"valueDefault":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}),
|
node("words", "separate_u32x16", 1, json!({"valueDefault":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}),
|
||||||
@@ -54,7 +63,7 @@ pub(crate) fn full_cull_graph() -> Value {
|
|||||||
node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})),
|
node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})),
|
||||||
node("class", "and", 2, json!({}),
|
node("class", "and", 2, json!({}),
|
||||||
json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})),
|
json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})),
|
||||||
node("pipeline", "gltf_standard", 2,
|
node("pipeline", "material", 2,
|
||||||
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
||||||
json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"color":input("color","texture"),"depth":input("depth","texture")})),
|
json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"color":input("color","texture"),"depth":input("depth","texture")})),
|
||||||
node("frame", "frame_out", 3,
|
node("frame", "frame_out", 3,
|
||||||
@@ -68,12 +77,11 @@ pub(crate) fn full_cull_graph() -> Value {
|
|||||||
fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
|
fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
|
||||||
assert_eq!(contract("mesh").unwrap().version, 2);
|
assert_eq!(contract("mesh").unwrap().version, 2);
|
||||||
assert!(contract("pipeline").is_none());
|
assert!(contract("pipeline").is_none());
|
||||||
for key in [
|
assert!(contract("material").is_none());
|
||||||
"ground_plane",
|
let pipelines: PipelineDeclarations =
|
||||||
"gltf_standard",
|
serde_json::from_value(render_pipeline_declarations()).unwrap();
|
||||||
"gltf_standard_double_sided",
|
for key in ["unlit", "material", "material_double_sided"] {
|
||||||
] {
|
let contract = contract_for(key, &pipelines).unwrap();
|
||||||
let contract = contract(key).unwrap();
|
|
||||||
assert_eq!(contract.version, 2);
|
assert_eq!(contract.version, 2);
|
||||||
assert!(contract.is_raster_draw());
|
assert!(contract.is_raster_draw());
|
||||||
}
|
}
|
||||||
@@ -90,7 +98,7 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
|
|||||||
("localAabb", SemanticType::LocalAabb)
|
("localAabb", SemanticType::LocalAabb)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
let predicate = contract("gltf_standard")
|
let predicate = contract_for("material", &pipelines)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.inputs
|
.inputs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -376,7 +384,7 @@ fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn raster_executors_reject_removed_pipeline_parameter() {
|
fn raster_executors_reject_removed_pipeline_parameter() {
|
||||||
let mut graph = full_cull_graph();
|
let mut graph = full_cull_graph();
|
||||||
graph["nodes"][8]["parameters"]["pipeline"] = json!("gltf_standard");
|
graph["nodes"][8]["parameters"]["pipeline"] = json!("material");
|
||||||
let error = compile_value(graph).unwrap_err();
|
let error = compile_value(graph).unwrap_err();
|
||||||
assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID");
|
assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID");
|
||||||
assert_eq!(error.details["path"], "nodes[8].parameters");
|
assert_eq!(error.details["path"], "nodes[8].parameters");
|
||||||
@@ -426,7 +434,7 @@ fn sibling_raster_writers_form_one_ordered_physical_pass() {
|
|||||||
9,
|
9,
|
||||||
node(
|
node(
|
||||||
"sibling",
|
"sibling",
|
||||||
"ground_plane",
|
"unlit",
|
||||||
2,
|
2,
|
||||||
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
||||||
json!({"mesh":input("mesh","mesh"),"color":input("color","texture"),"depth":input("depth","texture")}),
|
json!({"mesh":input("mesh","mesh"),"color":input("color","texture"),"depth":input("depth","texture")}),
|
||||||
@@ -476,12 +484,13 @@ fn expression_provenance_rejects_cross_mesh_values() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn implicit_pipeline_graph() -> Value {
|
fn implicit_pipeline_graph() -> Value {
|
||||||
json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1, "nodes": [
|
json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1,
|
||||||
|
"pipelines": render_pipeline_declarations(), "nodes": [
|
||||||
node("mesh", "mesh", 2, json!({}), json!({})),
|
node("mesh", "mesh", 2, json!({}), json!({})),
|
||||||
node("first", "ground_plane", 2,
|
node("first", "unlit", 2,
|
||||||
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
||||||
json!({"mesh":input("mesh","mesh")})),
|
json!({"mesh":input("mesh","mesh")})),
|
||||||
node("second", "gltf_standard", 2,
|
node("second", "material", 2,
|
||||||
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
||||||
json!({"mesh":input("mesh","mesh"),"color":input("first","color"),"depth":input("first","depth")})),
|
json!({"mesh":input("mesh","mesh"),"color":input("first","color"),"depth":input("first","depth")})),
|
||||||
node("frame", "frame_out", 3,
|
node("frame", "frame_out", 3,
|
||||||
@@ -493,12 +502,12 @@ fn implicit_pipeline_graph() -> Value {
|
|||||||
fn three_raster_graph() -> Value {
|
fn three_raster_graph() -> Value {
|
||||||
let mut graph = full_cull_graph();
|
let mut graph = full_cull_graph();
|
||||||
let nodes = graph["nodes"].as_array_mut().unwrap();
|
let nodes = graph["nodes"].as_array_mut().unwrap();
|
||||||
nodes[8]["executor"] = json!({"key":"ground_plane","version":2});
|
nodes[8]["executor"] = json!({"key":"unlit","version":2});
|
||||||
nodes.insert(
|
nodes.insert(
|
||||||
9,
|
9,
|
||||||
node(
|
node(
|
||||||
"standard",
|
"standard",
|
||||||
"gltf_standard",
|
"material",
|
||||||
2,
|
2,
|
||||||
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[1,0,0,1],"predicateDefault":true}),
|
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[1,0,0,1],"predicateDefault":true}),
|
||||||
json!({"mesh":input("mesh","mesh"),"color":input("pipeline","color"),"depth":input("pipeline","depth")}),
|
json!({"mesh":input("mesh","mesh"),"color":input("pipeline","color"),"depth":input("pipeline","depth")}),
|
||||||
@@ -508,7 +517,7 @@ fn three_raster_graph() -> Value {
|
|||||||
10,
|
10,
|
||||||
node(
|
node(
|
||||||
"double",
|
"double",
|
||||||
"gltf_standard_double_sided",
|
"material_double_sided",
|
||||||
2,
|
2,
|
||||||
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":0.5,"clearColor":[0,1,0,1],"predicateDefault":true}),
|
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":0.5,"clearColor":[0,1,0,1],"predicateDefault":true}),
|
||||||
json!({"mesh":input("mesh","mesh"),"color":input("standard","color"),"depth":input("standard","depth")}),
|
json!({"mesh":input("mesh","mesh"),"color":input("standard","color"),"depth":input("standard","depth")}),
|
||||||
@@ -857,7 +866,9 @@ fn runtime_rejects_noncanonical_physical_passes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn contract_v4_declares_strict_default_policies() {
|
fn contract_v4_declares_strict_default_policies() {
|
||||||
let pipeline = contract("gltf_standard").unwrap();
|
let declarations: PipelineDeclarations =
|
||||||
|
serde_json::from_value(render_pipeline_declarations()).unwrap();
|
||||||
|
let pipeline = contract_for("material", &declarations).unwrap();
|
||||||
assert_eq!(pipeline.version, 2);
|
assert_eq!(pipeline.version, 2);
|
||||||
assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None);
|
assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1115,7 +1126,7 @@ fn fullscreen_output_is_a_valid_raster_attachment_root() {
|
|||||||
11,
|
11,
|
||||||
node(
|
node(
|
||||||
"later",
|
"later",
|
||||||
"ground_plane",
|
"unlit",
|
||||||
2,
|
2,
|
||||||
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
|
||||||
pub(super) use pipeline::{encode_compiled, encode_immediate};
|
pub(super) use pipeline::encode_compiled;
|
||||||
|
|||||||
@@ -1,28 +1,23 @@
|
|||||||
use crate::renderer::{
|
use crate::renderer::{
|
||||||
gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledGraph, PipelineLibrary,
|
frame_data::FrameData, gpu_scene::GpuSceneCache, material::MaterialResources,
|
||||||
PreparedExecution,
|
ActiveCompiledGraph, PipelineLibrary, PreparedExecution,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::super::scene::Scene;
|
pub(crate) fn encode_compiled(
|
||||||
|
|
||||||
pub(crate) fn encode_compiled<T: Scene>(
|
|
||||||
encoder: &mut wgpu::CommandEncoder,
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
surface: &wgpu::TextureView,
|
surface: &wgpu::TextureView,
|
||||||
active: &ActiveCompiledGraph,
|
active: &ActiveCompiledGraph,
|
||||||
scene: &T,
|
frame_data: &FrameData,
|
||||||
gpu: &GpuSceneCache,
|
gpu: &GpuSceneCache,
|
||||||
pipelines: &PipelineLibrary,
|
pipelines: &PipelineLibrary,
|
||||||
materials: &MaterialResources,
|
materials: &MaterialResources,
|
||||||
planes: Option<&[[f32; 4]; 6]>,
|
planes: Option<&[[f32; 4]; 6]>,
|
||||||
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
|
|
||||||
) -> Result<(), &'static str> {
|
) -> Result<(), &'static str> {
|
||||||
use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
|
use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
|
||||||
for compute in &active.compute {
|
for compute in &active.compute {
|
||||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
label: Some(&compute.name),
|
label: Some(&compute.name),
|
||||||
timestamp_writes: profile
|
timestamp_writes: None,
|
||||||
.as_deref_mut()
|
|
||||||
.and_then(|profile| profile.compute_writes(&compute.name)),
|
|
||||||
});
|
});
|
||||||
pass.set_pipeline(&compute.pipeline);
|
pass.set_pipeline(&compute.pipeline);
|
||||||
pass.dispatch_workgroups(
|
pass.dispatch_workgroups(
|
||||||
@@ -150,7 +145,7 @@ pub(crate) fn encode_compiled<T: Scene>(
|
|||||||
color_attachments: &colors,
|
color_attachments: &colors,
|
||||||
depth_stencil_attachment: depth,
|
depth_stencil_attachment: depth,
|
||||||
occlusion_query_set: None,
|
occlusion_query_set: None,
|
||||||
timestamp_writes: profile.as_deref_mut().and_then(|p| p.render_writes(&label)),
|
timestamp_writes: None,
|
||||||
});
|
});
|
||||||
for &member in &physical.executions {
|
for &member in &physical.executions {
|
||||||
match active
|
match active
|
||||||
@@ -178,7 +173,7 @@ pub(crate) fn encode_compiled<T: Scene>(
|
|||||||
.instance_traversal
|
.instance_traversal
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or("compiled graph instance traversal missing")?;
|
.ok_or("compiled graph instance traversal missing")?;
|
||||||
for (i, group) in scene.bind_groups().iter().enumerate() {
|
for (i, group) in frame_data.bind_groups().iter().enumerate() {
|
||||||
pass.set_bind_group(i as u32, group, &[]);
|
pass.set_bind_group(i as u32, group, &[]);
|
||||||
}
|
}
|
||||||
if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = (
|
if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = (
|
||||||
@@ -229,38 +224,3 @@ pub(crate) fn encode_compiled<T: Scene>(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn encode_immediate(
|
|
||||||
encoder: &mut wgpu::CommandEncoder,
|
|
||||||
color: &wgpu::TextureView,
|
|
||||||
depth: &wgpu::TextureView,
|
|
||||||
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
|
|
||||||
) {
|
|
||||||
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
|
||||||
label: Some("No active render graph"),
|
|
||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
|
||||||
depth_slice: None,
|
|
||||||
view: color,
|
|
||||||
resolve_target: None,
|
|
||||||
ops: wgpu::Operations {
|
|
||||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
|
||||||
r: 0.,
|
|
||||||
g: 0.,
|
|
||||||
b: 0.,
|
|
||||||
a: 1.,
|
|
||||||
}),
|
|
||||||
store: wgpu::StoreOp::Store,
|
|
||||||
},
|
|
||||||
})],
|
|
||||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
|
||||||
view: depth,
|
|
||||||
depth_ops: Some(wgpu::Operations {
|
|
||||||
load: wgpu::LoadOp::Clear(1.),
|
|
||||||
store: wgpu::StoreOp::Store,
|
|
||||||
}),
|
|
||||||
stencil_ops: None,
|
|
||||||
}),
|
|
||||||
occlusion_query_set: None,
|
|
||||||
timestamp_writes: profile.and_then(|p| p.render_writes("no-active-graph")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
use crate::render_data::camera::Camera;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::renderer::{self, PipelineLibrary};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub struct UniformResource {
|
||||||
|
pub buffer: wgpu::Buffer,
|
||||||
|
pub bind_group: wgpu::BindGroup,
|
||||||
|
pub bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
|
||||||
|
pub struct FrameMetadata {
|
||||||
|
pub resolution: [f32; 2],
|
||||||
|
time: f32,
|
||||||
|
_padding0: f32,
|
||||||
|
pub camera_position: [f32; 4],
|
||||||
|
}
|
||||||
|
impl FrameMetadata {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn new(dimension: ultraviolet::Vec2) -> Self {
|
||||||
|
Self {
|
||||||
|
resolution: dimension.into(),
|
||||||
|
camera_position: [0., 0., 0., 1.],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
|
||||||
|
self.camera_position = [p.x, p.y, p.z, 1.];
|
||||||
|
}
|
||||||
|
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
|
||||||
|
self.resolution = d.into();
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
|
||||||
|
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("frame metadata"),
|
||||||
|
contents: bytemuck::bytes_of(&self),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
});
|
||||||
|
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("frame layout"),
|
||||||
|
entries: &[wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("frame group"),
|
||||||
|
layout: &bind_group_layout,
|
||||||
|
entries: &[wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: buffer.as_entire_binding(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
UniformResource {
|
||||||
|
buffer,
|
||||||
|
bind_group,
|
||||||
|
bind_group_layout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct FrameData {
|
||||||
|
uniform_buffers: [wgpu::Buffer; 2],
|
||||||
|
bind_groups: [wgpu::BindGroup; 2],
|
||||||
|
metadata: FrameMetadata,
|
||||||
|
camera: Camera,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameData {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub(crate) fn new(
|
||||||
|
context: &renderer::RendererContext,
|
||||||
|
resources: &mut PipelineLibrary,
|
||||||
|
) -> Self {
|
||||||
|
let dimensions = ultraviolet::Vec2::new(
|
||||||
|
context.surface_config.width as f32,
|
||||||
|
context.surface_config.height as f32,
|
||||||
|
);
|
||||||
|
let mut metadata = FrameMetadata::new(dimensions);
|
||||||
|
let camera = Camera::new(dimensions.x / dimensions.y);
|
||||||
|
metadata.set_camera_position(camera.position());
|
||||||
|
let frame = metadata.create_uniform_resource(&context.device);
|
||||||
|
let camera_uniform = camera.create_uniform_resource(&context.device);
|
||||||
|
resources
|
||||||
|
.set_bind_group_layouts(&[frame.bind_group_layout, camera_uniform.bind_group_layout]);
|
||||||
|
Self {
|
||||||
|
uniform_buffers: [frame.buffer, camera_uniform.buffer],
|
||||||
|
bind_groups: [frame.bind_group, camera_uniform.bind_group],
|
||||||
|
metadata,
|
||||||
|
camera,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bind_groups(&self) -> &[wgpu::BindGroup] {
|
||||||
|
&self.bind_groups
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn camera_mut(&mut self) -> &mut Camera {
|
||||||
|
&mut self.camera
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn frustum_planes(
|
||||||
|
&mut self,
|
||||||
|
) -> Result<[[f32; 4]; 6], crate::render_data::camera::FrustumError> {
|
||||||
|
self.camera.frustum_planes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resize(&mut self, width: f64, height: f64, queue: &wgpu::Queue) {
|
||||||
|
self.metadata
|
||||||
|
.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
|
||||||
|
self.camera
|
||||||
|
.update_aspect_ratio(width as f32 / height as f32);
|
||||||
|
self.write_uniforms(queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update(&mut self, queue: &wgpu::Queue) {
|
||||||
|
self.metadata.time = js_sys::Date::now() as f32 * 0.001;
|
||||||
|
self.metadata.set_camera_position(self.camera.position());
|
||||||
|
self.write_uniforms(queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_uniforms(&self, queue: &wgpu::Queue) {
|
||||||
|
queue.write_buffer(
|
||||||
|
&self.uniform_buffers[0],
|
||||||
|
0,
|
||||||
|
bytemuck::bytes_of(&self.metadata),
|
||||||
|
);
|
||||||
|
queue.write_buffer(
|
||||||
|
&self.uniform_buffers[1],
|
||||||
|
0,
|
||||||
|
bytemuck::bytes_of(&self.camera.view_proj),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,92 +1,15 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use bytemuck::{Pod, Zeroable};
|
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::{
|
use crate::render_data::{
|
||||||
gltf::{AlphaMode, ImageSource, ImportedScene, Material, SamplerMetadata, TextureReference},
|
upload::{AddressMode, FilterMode, Material, MaterialState, RenderDataUpload, SamplerMetadata},
|
||||||
render_data::MaterialKey,
|
MaterialKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE: u32 = 1 << 0;
|
|
||||||
const MR: u32 = 1 << 1;
|
|
||||||
const NORMAL: u32 = 1 << 2;
|
|
||||||
const OCCLUSION: u32 = 1 << 3;
|
|
||||||
const EMISSIVE: u32 = 1 << 4;
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
|
|
||||||
pub struct GpuMaterial {
|
|
||||||
pub base_color_factor: [f32; 4],
|
|
||||||
pub emissive_factor: [f32; 4],
|
|
||||||
pub surface_factors: [f32; 4],
|
|
||||||
pub alpha_optics: [f32; 4],
|
|
||||||
pub flags: [u32; 4],
|
|
||||||
pub uv_sets: [u32; 4],
|
|
||||||
/// Internal shader diagnostics; zero means the normal shaded view.
|
|
||||||
pub debug_extras: [u32; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
|
|
||||||
reference.filter(|r| r.tex_coord == 0).map_or(0, |_| bit)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Material> for GpuMaterial {
|
|
||||||
fn from(value: &Material) -> Self {
|
|
||||||
Self {
|
|
||||||
base_color_factor: value.base_color_factor,
|
|
||||||
emissive_factor: [
|
|
||||||
value.emissive_factor[0],
|
|
||||||
value.emissive_factor[1],
|
|
||||||
value.emissive_factor[2],
|
|
||||||
0.0,
|
|
||||||
],
|
|
||||||
surface_factors: [
|
|
||||||
value.metallic_factor,
|
|
||||||
value.roughness_factor,
|
|
||||||
value.normal_scale,
|
|
||||||
value.occlusion_strength,
|
|
||||||
],
|
|
||||||
alpha_optics: [
|
|
||||||
match value.alpha_mode {
|
|
||||||
AlphaMode::Opaque => 0.0,
|
|
||||||
AlphaMode::Mask => 1.0,
|
|
||||||
AlphaMode::Blend => 2.0,
|
|
||||||
},
|
|
||||||
value.alpha_cutoff,
|
|
||||||
value.ior,
|
|
||||||
if value.ior == 0.0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
flags: [
|
|
||||||
enabled(value.base_color_texture, BASE)
|
|
||||||
| enabled(value.metallic_roughness_texture, MR)
|
|
||||||
| enabled(value.normal_texture, NORMAL)
|
|
||||||
| enabled(value.occlusion_texture, OCCLUSION)
|
|
||||||
| enabled(value.emissive_texture, EMISSIVE),
|
|
||||||
u32::from(value.double_sided),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
],
|
|
||||||
uv_sets: [
|
|
||||||
value.base_color_texture.map_or(0, |x| x.tex_coord),
|
|
||||||
value.metallic_roughness_texture.map_or(0, |x| x.tex_coord),
|
|
||||||
value.normal_texture.map_or(0, |x| x.tex_coord),
|
|
||||||
value.occlusion_texture.map_or(0, |x| x.tex_coord),
|
|
||||||
],
|
|
||||||
debug_extras: [value.emissive_texture.map_or(0, |x| x.tex_coord), 0, 0, 0],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum MaterialError {
|
pub enum MaterialError {
|
||||||
#[error("external image sources are unsupported")]
|
|
||||||
ExternalImage,
|
|
||||||
#[error("unsupported image MIME type: {0}")]
|
#[error("unsupported image MIME type: {0}")]
|
||||||
Mime(String),
|
Mime(String),
|
||||||
#[error("image decode failed: {0}")]
|
#[error("image decode failed: {0}")]
|
||||||
@@ -97,8 +20,13 @@ pub enum MaterialError {
|
|||||||
InvalidRgba(&'static str),
|
InvalidRgba(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MaterialBinding {
|
||||||
|
group: wgpu::BindGroup,
|
||||||
|
uniform: wgpu::Buffer,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct PreparedMaterials {
|
pub(super) struct PreparedMaterials {
|
||||||
groups: HashMap<MaterialKey, wgpu::BindGroup>,
|
groups: HashMap<MaterialKey, MaterialBinding>,
|
||||||
textures: Vec<wgpu::Texture>,
|
textures: Vec<wgpu::Texture>,
|
||||||
views: Vec<[wgpu::TextureView; 2]>,
|
views: Vec<[wgpu::TextureView; 2]>,
|
||||||
samplers: Vec<wgpu::Sampler>,
|
samplers: Vec<wgpu::Sampler>,
|
||||||
@@ -106,8 +34,8 @@ pub(super) struct PreparedMaterials {
|
|||||||
|
|
||||||
pub struct MaterialResources {
|
pub struct MaterialResources {
|
||||||
pub layout: wgpu::BindGroupLayout,
|
pub layout: wgpu::BindGroupLayout,
|
||||||
groups: HashMap<MaterialKey, wgpu::BindGroup>,
|
groups: HashMap<MaterialKey, MaterialBinding>,
|
||||||
fallback: wgpu::BindGroup,
|
fallback: MaterialBinding,
|
||||||
fallback_views: Vec<wgpu::TextureView>,
|
fallback_views: Vec<wgpu::TextureView>,
|
||||||
fallback_sampler: wgpu::Sampler,
|
fallback_sampler: wgpu::Sampler,
|
||||||
textures: Vec<wgpu::Texture>,
|
textures: Vec<wgpu::Texture>,
|
||||||
@@ -208,12 +136,18 @@ fn slot_uses_srgb(slot: usize) -> bool {
|
|||||||
matches!(slot, 0 | 4)
|
matches!(slot, 0 | 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn address(value: &str) -> wgpu::AddressMode {
|
fn address(value: AddressMode) -> wgpu::AddressMode {
|
||||||
match value {
|
match value {
|
||||||
"ClampToEdge" => wgpu::AddressMode::ClampToEdge,
|
AddressMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
|
||||||
"MirroredRepeat" => wgpu::AddressMode::MirrorRepeat,
|
AddressMode::MirrorRepeat => wgpu::AddressMode::MirrorRepeat,
|
||||||
"Repeat" => wgpu::AddressMode::Repeat,
|
AddressMode::Repeat => wgpu::AddressMode::Repeat,
|
||||||
_ => unreachable!("gltf crate returned unknown wrap"),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter(value: FilterMode) -> wgpu::FilterMode {
|
||||||
|
match value {
|
||||||
|
FilterMode::Nearest => wgpu::FilterMode::Nearest,
|
||||||
|
FilterMode::Linear => wgpu::FilterMode::Linear,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,29 +161,13 @@ fn sampler_descriptor(metadata: Option<&SamplerMetadata>) -> wgpu::SamplerDescri
|
|||||||
wgpu::AddressMode::Repeat,
|
wgpu::AddressMode::Repeat,
|
||||||
),
|
),
|
||||||
|m| {
|
|m| {
|
||||||
let mag = match m.mag_filter.as_deref() {
|
(
|
||||||
Some("Nearest") => wgpu::FilterMode::Nearest,
|
filter(m.mag_filter),
|
||||||
Some("Linear") | None => wgpu::FilterMode::Linear,
|
filter(m.min_filter),
|
||||||
_ => unreachable!(),
|
filter(m.mipmap_filter),
|
||||||
};
|
address(m.address_u),
|
||||||
let (min, mip) = match m.min_filter.as_deref() {
|
address(m.address_v),
|
||||||
Some("Nearest") => (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest),
|
)
|
||||||
Some("Linear") => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
|
|
||||||
Some("NearestMipmapNearest") => {
|
|
||||||
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
|
|
||||||
}
|
|
||||||
Some("LinearMipmapNearest") => {
|
|
||||||
(wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
|
|
||||||
}
|
|
||||||
Some("NearestMipmapLinear") => {
|
|
||||||
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Linear)
|
|
||||||
}
|
|
||||||
Some("LinearMipmapLinear") | None => {
|
|
||||||
(wgpu::FilterMode::Linear, wgpu::FilterMode::Linear)
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
(mag, min, mip, address(&m.wrap_s), address(&m.wrap_t))
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
wgpu::SamplerDescriptor {
|
wgpu::SamplerDescriptor {
|
||||||
@@ -289,7 +207,7 @@ impl MaterialResources {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
label: Some("glTF material group 2"),
|
label: Some("render-data material group 2"),
|
||||||
entries: &entries,
|
entries: &entries,
|
||||||
});
|
});
|
||||||
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
|
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
|
||||||
@@ -333,11 +251,11 @@ impl MaterialResources {
|
|||||||
material: &Material,
|
material: &Material,
|
||||||
views: [&wgpu::TextureView; 5],
|
views: [&wgpu::TextureView; 5],
|
||||||
samplers: [&wgpu::Sampler; 5],
|
samplers: [&wgpu::Sampler; 5],
|
||||||
) -> wgpu::BindGroup {
|
) -> MaterialBinding {
|
||||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("material uniform"),
|
label: Some("material uniform"),
|
||||||
contents: bytemuck::bytes_of(&GpuMaterial::from(material)),
|
contents: bytemuck::bytes_of(&MaterialState::from(material)),
|
||||||
usage: wgpu::BufferUsages::UNIFORM,
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
});
|
});
|
||||||
let mut entries = vec![wgpu::BindGroupEntry {
|
let mut entries = vec![wgpu::BindGroupEntry {
|
||||||
binding: 0,
|
binding: 0,
|
||||||
@@ -355,30 +273,28 @@ impl MaterialResources {
|
|||||||
resource: wgpu::BindingResource::Sampler(sampler),
|
resource: wgpu::BindingResource::Sampler(sampler),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("material bind group"),
|
label: Some("material bind group"),
|
||||||
layout,
|
layout,
|
||||||
entries: &entries,
|
entries: &entries,
|
||||||
})
|
});
|
||||||
|
MaterialBinding { group, uniform }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn prepare(
|
pub(super) fn prepare(
|
||||||
&self,
|
&self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
scene: &ImportedScene,
|
scene: &RenderDataUpload,
|
||||||
) -> Result<PreparedMaterials, MaterialError> {
|
) -> Result<PreparedMaterials, MaterialError> {
|
||||||
let max_dimension = device.limits().max_texture_dimension_2d;
|
let max_dimension = device.limits().max_texture_dimension_2d;
|
||||||
let mut textures = Vec::with_capacity(scene.images.len());
|
let mut textures = Vec::with_capacity(scene.images.len());
|
||||||
let mut views = Vec::with_capacity(scene.images.len());
|
let mut views = Vec::with_capacity(scene.images.len());
|
||||||
for image in &scene.images {
|
for image in &scene.images {
|
||||||
if !matches!(image.source, ImageSource::BufferView(_)) {
|
let format = match image.mime_type.as_str() {
|
||||||
return Err(MaterialError::ExternalImage);
|
"image/png" => image::ImageFormat::Png,
|
||||||
}
|
"image/jpeg" => image::ImageFormat::Jpeg,
|
||||||
let format = match image.mime_type.as_deref() {
|
other => return Err(MaterialError::Mime(other.into())),
|
||||||
Some("image/png") => image::ImageFormat::Png,
|
|
||||||
Some("image/jpeg") => image::ImageFormat::Jpeg,
|
|
||||||
other => return Err(MaterialError::Mime(other.unwrap_or("missing").into())),
|
|
||||||
};
|
};
|
||||||
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
|
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
|
||||||
let (width, height, rgba) = normalize_rgba(decoded);
|
let (width, height, rgba) = normalize_rgba(decoded);
|
||||||
@@ -386,7 +302,7 @@ impl MaterialResources {
|
|||||||
let (texture, image_views) = upload_rgba(
|
let (texture, image_views) = upload_rgba(
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
"glTF embedded image",
|
"render-data image",
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
bytes_per_row,
|
bytes_per_row,
|
||||||
@@ -464,7 +380,24 @@ impl MaterialResources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup {
|
pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup {
|
||||||
self.groups.get(&key).unwrap_or(&self.fallback)
|
&self.groups.get(&key).unwrap_or(&self.fallback).group
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn synchronize(
|
||||||
|
&self,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
rows: &[(MaterialKey, [u32; MaterialState::LANES as usize])],
|
||||||
|
) {
|
||||||
|
for (key, words) in rows {
|
||||||
|
let binding = self
|
||||||
|
.groups
|
||||||
|
.get(key)
|
||||||
|
.or_else(|| (*key == MaterialKey::DEFAULT).then_some(&self.fallback));
|
||||||
|
if let Some(binding) = binding {
|
||||||
|
let state = MaterialState::from_words(*words);
|
||||||
|
queue.write_buffer(&binding.uniform, 0, bytemuck::bytes_of(&state));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,30 +443,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn material_uniform_is_112_bytes() {
|
fn material_uniform_is_112_bytes() {
|
||||||
assert_eq!(std::mem::size_of::<GpuMaterial>(), 112);
|
assert_eq!(std::mem::size_of::<MaterialState>(), 112);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn texcoord_one_disables_slot() {
|
fn texcoord_one_disables_slot() {
|
||||||
let mut m = Material::default();
|
let mut m = Material::default();
|
||||||
m.base_color_texture = Some(TextureReference {
|
m.base_color_texture = Some(crate::render_data::upload::TextureReference {
|
||||||
texture: 0,
|
texture: 0,
|
||||||
tex_coord: 1,
|
tex_coord: 1,
|
||||||
});
|
});
|
||||||
assert_eq!(GpuMaterial::from(&m).flags[0] & BASE, 0);
|
assert_eq!(MaterialState::from(&m).flags[0] & 1, 0);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn material_packing_includes_ior_f0_flags_and_uv_sets() {
|
fn material_packing_includes_ior_f0_flags_and_uv_sets() {
|
||||||
let mut m = Material::default();
|
let mut m = Material::default();
|
||||||
m.ior = 2.0;
|
m.ior = 2.0;
|
||||||
m.double_sided = true;
|
m.double_sided = true;
|
||||||
m.normal_texture = Some(TextureReference {
|
m.normal_texture = Some(crate::render_data::upload::TextureReference {
|
||||||
texture: 4,
|
texture: 4,
|
||||||
tex_coord: 0,
|
tex_coord: 0,
|
||||||
});
|
});
|
||||||
let gpu = GpuMaterial::from(&m);
|
let gpu = MaterialState::from(&m);
|
||||||
assert_eq!(gpu.alpha_optics[2], 2.0);
|
assert_eq!(gpu.alpha_optics[2], 2.0);
|
||||||
assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6);
|
assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6);
|
||||||
assert_eq!(gpu.flags[0] & NORMAL, NORMAL);
|
assert_eq!(gpu.flags[0] & (1 << 2), 1 << 2);
|
||||||
assert_eq!(gpu.flags[1], 1);
|
assert_eq!(gpu.flags[1], 1);
|
||||||
assert_eq!(gpu.uv_sets[2], 0);
|
assert_eq!(gpu.uv_sets[2], 0);
|
||||||
}
|
}
|
||||||
@@ -541,18 +474,18 @@ mod tests {
|
|||||||
fn explicit_ior_sentinel_packs_unit_f0() {
|
fn explicit_ior_sentinel_packs_unit_f0() {
|
||||||
let mut material = Material::default();
|
let mut material = Material::default();
|
||||||
material.ior = 0.0;
|
material.ior = 0.0;
|
||||||
let gpu = GpuMaterial::from(&material);
|
let gpu = MaterialState::from(&material);
|
||||||
assert_eq!(gpu.alpha_optics[2], 0.0);
|
assert_eq!(gpu.alpha_optics[2], 0.0);
|
||||||
assert_eq!(gpu.alpha_optics[3], 1.0);
|
assert_eq!(gpu.alpha_optics[3], 1.0);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn sampler_translation_is_exact() {
|
fn sampler_translation_is_exact() {
|
||||||
let m = SamplerMetadata {
|
let m = SamplerMetadata {
|
||||||
index: 0,
|
mag_filter: FilterMode::Nearest,
|
||||||
mag_filter: Some("Nearest".into()),
|
min_filter: FilterMode::Linear,
|
||||||
min_filter: Some("LinearMipmapNearest".into()),
|
mipmap_filter: FilterMode::Nearest,
|
||||||
wrap_s: "ClampToEdge".into(),
|
address_u: AddressMode::ClampToEdge,
|
||||||
wrap_t: "MirroredRepeat".into(),
|
address_v: AddressMode::MirrorRepeat,
|
||||||
};
|
};
|
||||||
let d = sampler_descriptor(Some(&m));
|
let d = sampler_descriptor(Some(&m));
|
||||||
assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest);
|
assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest);
|
||||||
|
|||||||
+172
-461
File diff suppressed because it is too large
Load Diff
@@ -171,11 +171,11 @@ impl PipelineLibrary {
|
|||||||
key
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers the glTF-only layout, preserving scene groups at 0 and 1.
|
/// Registers the optional material layout after the frame and render-data groups.
|
||||||
pub fn set_material_bind_group_layout(&mut self, layout: &wgpu::BindGroupLayout) {
|
pub fn set_material_bind_group_layout(&mut self, layout: &wgpu::BindGroupLayout) {
|
||||||
let base = self
|
let base = self
|
||||||
.default_layout
|
.default_layout
|
||||||
.expect("scene layouts must be registered first");
|
.expect("render-data layouts must be registered first");
|
||||||
let mut layouts = self.layout_bindings[&base].clone();
|
let mut layouts = self.layout_bindings[&base].clone();
|
||||||
layouts.push(layout.clone());
|
layouts.push(layout.clone());
|
||||||
let key = PipelineLayoutKey(self.next_layout);
|
let key = PipelineLayoutKey(self.next_layout);
|
||||||
@@ -184,12 +184,13 @@ impl PipelineLibrary {
|
|||||||
self.material_layout = Some(key);
|
self.material_layout = Some(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compatibility_spec(
|
fn authored_spec(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
|
||||||
layouts: &[wgpu::VertexBufferLayout],
|
layouts: &[wgpu::VertexBufferLayout],
|
||||||
shader: &str,
|
shader: &str,
|
||||||
format: wgpu::TextureFormat,
|
format: wgpu::TextureFormat,
|
||||||
|
material: bool,
|
||||||
|
double_sided: bool,
|
||||||
) -> RenderPipelineSpec {
|
) -> RenderPipelineSpec {
|
||||||
let stage = |entry: &str| OwnedProgrammableStage {
|
let stage = |entry: &str| OwnedProgrammableStage {
|
||||||
shader_source: shader.to_owned(),
|
shader_source: shader.to_owned(),
|
||||||
@@ -198,7 +199,7 @@ impl PipelineLibrary {
|
|||||||
zero_initialize_workgroup_memory: true,
|
zero_initialize_workgroup_memory: true,
|
||||||
};
|
};
|
||||||
RenderPipelineSpec {
|
RenderPipelineSpec {
|
||||||
layout: if name.starts_with("gltf_") {
|
layout: if material {
|
||||||
self.material_layout.or(self.default_layout)
|
self.material_layout.or(self.default_layout)
|
||||||
} else {
|
} else {
|
||||||
self.default_layout
|
self.default_layout
|
||||||
@@ -214,7 +215,7 @@ impl PipelineLibrary {
|
|||||||
.collect(),
|
.collect(),
|
||||||
fragment: Some(stage("fs_main")),
|
fragment: Some(stage("fs_main")),
|
||||||
primitive: wgpu::PrimitiveState {
|
primitive: wgpu::PrimitiveState {
|
||||||
cull_mode: (name != "gltf_standard_double_sided").then_some(wgpu::Face::Back),
|
cull_mode: (!double_sided).then_some(wgpu::Face::Back),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
depth_stencil: Some(wgpu::DepthStencilState {
|
depth_stencil: Some(wgpu::DepthStencilState {
|
||||||
@@ -330,7 +331,7 @@ impl PipelineLibrary {
|
|||||||
pipeline_key
|
pipeline_key
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a graph-owned scene pipeline from its authored declaration.
|
/// Creates a graph-owned render-data pipeline from its authored declaration.
|
||||||
pub(crate) fn get_or_create_authored_pipeline(
|
pub(crate) fn get_or_create_authored_pipeline(
|
||||||
&mut self,
|
&mut self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
@@ -338,14 +339,18 @@ impl PipelineLibrary {
|
|||||||
layouts: &[wgpu::VertexBufferLayout],
|
layouts: &[wgpu::VertexBufferLayout],
|
||||||
format: wgpu::TextureFormat,
|
format: wgpu::TextureFormat,
|
||||||
) -> PipelineKey {
|
) -> PipelineKey {
|
||||||
let mut spec =
|
let mut spec = self.authored_spec(
|
||||||
self.compatibility_spec(&declaration.name, layouts, &declaration.shader, format);
|
layouts,
|
||||||
|
&declaration.shader,
|
||||||
|
format,
|
||||||
|
declaration.material,
|
||||||
|
declaration.double_sided,
|
||||||
|
);
|
||||||
spec.vertex.entry_point = declaration.vertex_entry.clone();
|
spec.vertex.entry_point = declaration.vertex_entry.clone();
|
||||||
spec.fragment
|
spec.fragment
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.expect("scene pipelines have fragment stages")
|
.expect("render-data pipelines have fragment stages")
|
||||||
.entry_point = declaration.fragment_entry.clone();
|
.entry_point = declaration.fragment_entry.clone();
|
||||||
spec.primitive.cull_mode = (!declaration.double_sided).then_some(wgpu::Face::Back);
|
|
||||||
self.get_or_create_from_spec(device, &spec, Some(&declaration.name))
|
self.get_or_create_from_spec(device, &spec, Some(&declaration.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,11 +468,12 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn spec() -> RenderPipelineSpec {
|
fn spec() -> RenderPipelineSpec {
|
||||||
PipelineLibrary::new().compatibility_spec(
|
PipelineLibrary::new().authored_spec(
|
||||||
"x",
|
|
||||||
&[],
|
&[],
|
||||||
"shader",
|
"shader",
|
||||||
wgpu::TextureFormat::Rgba8Unorm,
|
wgpu::TextureFormat::Rgba8Unorm,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,537 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::{HashMap, VecDeque},
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
use wasm_bindgen::JsValue;
|
|
||||||
|
|
||||||
// A frame can contain every logical execution as a singleton physical pass and
|
|
||||||
// one instance-traversal compute pass.
|
|
||||||
pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_EXECUTIONS + 1;
|
|
||||||
#[cfg(any(target_arch = "wasm32", test))]
|
|
||||||
const SLOT_COUNT: usize = 4;
|
|
||||||
#[cfg(any(target_arch = "wasm32", test))]
|
|
||||||
const QUERY_COUNT: u32 = (MAX_PROFILE_PASSES * 2) as u32;
|
|
||||||
#[cfg(any(target_arch = "wasm32", test))]
|
|
||||||
const USED_RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8;
|
|
||||||
#[cfg(any(target_arch = "wasm32", test))]
|
|
||||||
const RESOLVE_SIZE: u64 = USED_RESOLVE_SIZE.next_multiple_of(wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT);
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
enum SlotState {
|
|
||||||
Free,
|
|
||||||
Encoding,
|
|
||||||
Mapping,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Slot {
|
|
||||||
queries: wgpu::QuerySet,
|
|
||||||
resolve: wgpu::Buffer,
|
|
||||||
read: wgpu::Buffer,
|
|
||||||
state: SlotState,
|
|
||||||
}
|
|
||||||
struct Completion {
|
|
||||||
slot: usize,
|
|
||||||
epoch: u64,
|
|
||||||
ids: Vec<String>,
|
|
||||||
values: Option<Vec<u64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct ProfileFrame {
|
|
||||||
pub query_set: wgpu::QuerySet,
|
|
||||||
slot: usize,
|
|
||||||
identity: String,
|
|
||||||
ids: Vec<String>,
|
|
||||||
invalid: bool,
|
|
||||||
}
|
|
||||||
pub(crate) struct ProfileMap {
|
|
||||||
slot: usize,
|
|
||||||
epoch: u64,
|
|
||||||
ids: Vec<String>,
|
|
||||||
count: u32,
|
|
||||||
}
|
|
||||||
impl ProfileFrame {
|
|
||||||
fn allocate(&mut self, id: &str) -> Option<u32> {
|
|
||||||
allocate_id(&mut self.ids, &mut self.invalid, id)
|
|
||||||
}
|
|
||||||
pub fn render_writes(&mut self, id: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
|
|
||||||
let first = self.allocate(id)?;
|
|
||||||
Some(wgpu::RenderPassTimestampWrites {
|
|
||||||
query_set: &self.query_set,
|
|
||||||
beginning_of_pass_write_index: Some(first),
|
|
||||||
end_of_pass_write_index: Some(first + 1),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pub fn compute_writes(&mut self, id: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
|
|
||||||
let first = self.allocate(id)?;
|
|
||||||
Some(wgpu::ComputePassTimestampWrites {
|
|
||||||
query_set: &self.query_set,
|
|
||||||
beginning_of_pass_write_index: Some(first),
|
|
||||||
end_of_pass_write_index: Some(first + 1),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct Profiler {
|
|
||||||
enabled: bool,
|
|
||||||
available: bool,
|
|
||||||
slots: Vec<Slot>,
|
|
||||||
completions: Arc<Mutex<Vec<Completion>>>,
|
|
||||||
epoch: u64,
|
|
||||||
identity: String,
|
|
||||||
period_ns: f64,
|
|
||||||
samples: HashMap<String, VecDeque<(f64, f64)>>,
|
|
||||||
last_snapshot_ms: f64,
|
|
||||||
dropped: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Profiler {
|
|
||||||
#[cfg(any(target_arch = "wasm32", test))]
|
|
||||||
pub fn requested_features(requested: bool, supported: wgpu::Features) -> wgpu::Features {
|
|
||||||
if requested && supported.contains(wgpu::Features::TIMESTAMP_QUERY) {
|
|
||||||
wgpu::Features::TIMESTAMP_QUERY
|
|
||||||
} else {
|
|
||||||
wgpu::Features::empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
pub async fn new(requested: bool, device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
|
|
||||||
let available = requested && device.features().contains(wgpu::Features::TIMESTAMP_QUERY);
|
|
||||||
let mut slots = Vec::new();
|
|
||||||
if available {
|
|
||||||
device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
|
||||||
device.push_error_scope(wgpu::ErrorFilter::Validation);
|
|
||||||
for _ in 0..SLOT_COUNT {
|
|
||||||
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
|
|
||||||
label: Some("profile timestamps"),
|
|
||||||
ty: wgpu::QueryType::Timestamp,
|
|
||||||
count: QUERY_COUNT,
|
|
||||||
});
|
|
||||||
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
|
|
||||||
label: Some("profile resolve"),
|
|
||||||
size: RESOLVE_SIZE,
|
|
||||||
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
|
|
||||||
mapped_at_creation: false,
|
|
||||||
});
|
|
||||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
|
||||||
label: Some("profile readback"),
|
|
||||||
size: RESOLVE_SIZE,
|
|
||||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
|
||||||
mapped_at_creation: false,
|
|
||||||
});
|
|
||||||
slots.push(Slot {
|
|
||||||
queries,
|
|
||||||
resolve,
|
|
||||||
read,
|
|
||||||
state: SlotState::Free,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let allocation_failed = if available {
|
|
||||||
// Pop both scopes before yielding: WebGPU's scope stack must be unwound
|
|
||||||
// synchronously, even though completion of each pop is asynchronous.
|
|
||||||
let validation = device.pop_error_scope();
|
|
||||||
let oom = device.pop_error_scope();
|
|
||||||
let (validation, oom) = futures::join!(validation, oom);
|
|
||||||
validation.is_some() || oom.is_some()
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
if allocation_failed {
|
|
||||||
slots.clear();
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
enabled: requested,
|
|
||||||
available: available && !allocation_failed,
|
|
||||||
slots,
|
|
||||||
completions: Default::default(),
|
|
||||||
epoch: 0,
|
|
||||||
identity: String::new(),
|
|
||||||
period_ns: queue.get_timestamp_period() as f64,
|
|
||||||
samples: Default::default(),
|
|
||||||
last_snapshot_ms: 0.0,
|
|
||||||
dropped: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn begin(&mut self, identity: impl FnOnce() -> String) -> Option<ProfileFrame> {
|
|
||||||
self.drain();
|
|
||||||
let Some((slot, identity)) = profile_gate(self.enabled, self.available, || {
|
|
||||||
begin_transition(self.slots.iter_mut().map(|slot| &mut slot.state))
|
|
||||||
.map(|slot| (slot, identity()))
|
|
||||||
}) else {
|
|
||||||
if self.enabled && self.available {
|
|
||||||
self.dropped += 1;
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
Some(ProfileFrame {
|
|
||||||
query_set: self.slots[slot].queries.clone(),
|
|
||||||
slot,
|
|
||||||
identity,
|
|
||||||
ids: Vec::new(),
|
|
||||||
invalid: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pub fn cancel(&mut self, frame: ProfileFrame) {
|
|
||||||
cancel_state(&mut self.slots[frame.slot].state);
|
|
||||||
}
|
|
||||||
pub fn finish(
|
|
||||||
&mut self,
|
|
||||||
encoder: &mut wgpu::CommandEncoder,
|
|
||||||
frame: ProfileFrame,
|
|
||||||
) -> Option<ProfileMap> {
|
|
||||||
let count = match finish_transition(
|
|
||||||
&mut self.slots[frame.slot].state,
|
|
||||||
frame.invalid,
|
|
||||||
frame.ids.len(),
|
|
||||||
) {
|
|
||||||
FinishAction::Cancel => return None,
|
|
||||||
FinishAction::Resolve(count) => count,
|
|
||||||
};
|
|
||||||
if frame.identity != self.identity {
|
|
||||||
self.identity = frame.identity;
|
|
||||||
self.epoch = self.epoch.wrapping_add(1);
|
|
||||||
self.samples.clear();
|
|
||||||
}
|
|
||||||
let slot = frame.slot;
|
|
||||||
let epoch = self.epoch;
|
|
||||||
let ids = frame.ids;
|
|
||||||
encoder.resolve_query_set(
|
|
||||||
&self.slots[slot].queries,
|
|
||||||
0..count,
|
|
||||||
&self.slots[slot].resolve,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
encoder.copy_buffer_to_buffer(
|
|
||||||
&self.slots[slot].resolve,
|
|
||||||
0,
|
|
||||||
&self.slots[slot].read,
|
|
||||||
0,
|
|
||||||
count as u64 * 8,
|
|
||||||
);
|
|
||||||
Some(ProfileMap {
|
|
||||||
slot,
|
|
||||||
epoch,
|
|
||||||
ids,
|
|
||||||
count,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pub fn map(&mut self, request: ProfileMap) {
|
|
||||||
let ProfileMap {
|
|
||||||
slot,
|
|
||||||
epoch,
|
|
||||||
ids,
|
|
||||||
count,
|
|
||||||
} = request;
|
|
||||||
self.slots[slot].state = SlotState::Mapping;
|
|
||||||
let buffer = self.slots[slot].read.clone();
|
|
||||||
let completions = self.completions.clone();
|
|
||||||
buffer
|
|
||||||
.clone()
|
|
||||||
.slice(..count as u64 * 8)
|
|
||||||
.map_async(wgpu::MapMode::Read, move |result| {
|
|
||||||
let values = result.ok().map(|_| {
|
|
||||||
let bytes = buffer.slice(..count as u64 * 8).get_mapped_range();
|
|
||||||
let values = bytes
|
|
||||||
.chunks_exact(8)
|
|
||||||
.map(|x| u64::from_le_bytes(x.try_into().unwrap()))
|
|
||||||
.collect();
|
|
||||||
drop(bytes);
|
|
||||||
buffer.unmap();
|
|
||||||
values
|
|
||||||
});
|
|
||||||
if let Ok(mut completions) = completions.lock() {
|
|
||||||
completions.push(Completion {
|
|
||||||
slot,
|
|
||||||
epoch,
|
|
||||||
ids,
|
|
||||||
values,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fn drain(&mut self) {
|
|
||||||
let completions = if let Ok(mut queue) = self.completions.lock() {
|
|
||||||
queue.drain(..).collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for c in completions {
|
|
||||||
let Some(v) = completion_transition(
|
|
||||||
&mut self.slots[c.slot].state,
|
|
||||||
c.values,
|
|
||||||
c.epoch,
|
|
||||||
self.epoch,
|
|
||||||
&mut self.available,
|
|
||||||
&mut self.samples,
|
|
||||||
) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for (id, pair) in c.ids.into_iter().zip(v.chunks_exact(2)) {
|
|
||||||
if let Some(ms) = validate(pair[0], pair[1], self.period_ns) {
|
|
||||||
let q = self.samples.entry(id).or_default();
|
|
||||||
q.push_back((js_sys::Date::now(), ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn snapshot_json(&mut self, now: f64) -> Option<JsValue> {
|
|
||||||
self.drain();
|
|
||||||
profile_gate(self.enabled, self.available, || Some(()))?;
|
|
||||||
(now - self.last_snapshot_ms >= 250.0).then_some(())?;
|
|
||||||
self.last_snapshot_ms = now;
|
|
||||||
let cutoff = now - 1000.0;
|
|
||||||
let mut passes = serde_json::Map::new();
|
|
||||||
for (id, q) in &mut self.samples {
|
|
||||||
while q.front().is_some_and(|x| x.0 < cutoff) {
|
|
||||||
q.pop_front();
|
|
||||||
}
|
|
||||||
if !q.is_empty() {
|
|
||||||
passes.insert(
|
|
||||||
id.clone(),
|
|
||||||
serde_json::json!(q.iter().map(|x| x.1).sum::<f64>() / q.len() as f64),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let value = serde_json::json!({"type":"profile-snapshot","requested":self.enabled,"available":self.available,"epoch":self.epoch,"graph":self.identity,"passes":passes,"dropped":self.dropped});
|
|
||||||
js_sys::JSON::parse(&value.to_string()).ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn allocate_id(ids: &mut Vec<String>, invalid: &mut bool, id: &str) -> Option<u32> {
|
|
||||||
if ids.len() >= MAX_PROFILE_PASSES {
|
|
||||||
*invalid = true;
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let first = ids.len() as u32 * 2;
|
|
||||||
ids.push(id.to_owned());
|
|
||||||
Some(first)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn profile_gate<T>(enabled: bool, available: bool, f: impl FnOnce() -> Option<T>) -> Option<T> {
|
|
||||||
(enabled && available).then(f).flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin_transition<'a>(states: impl IntoIterator<Item = &'a mut SlotState>) -> Option<usize> {
|
|
||||||
for (slot, state) in states.into_iter().enumerate() {
|
|
||||||
if *state == SlotState::Free {
|
|
||||||
*state = SlotState::Encoding;
|
|
||||||
return Some(slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
enum FinishAction {
|
|
||||||
Cancel,
|
|
||||||
Resolve(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_transition(state: &mut SlotState, invalid: bool, id_count: usize) -> FinishAction {
|
|
||||||
if *state != SlotState::Encoding || invalid || id_count == 0 {
|
|
||||||
cancel_state(state);
|
|
||||||
FinishAction::Cancel
|
|
||||||
} else {
|
|
||||||
FinishAction::Resolve(id_count as u32 * 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn completion_transition(
|
|
||||||
state: &mut SlotState,
|
|
||||||
values: Option<Vec<u64>>,
|
|
||||||
completion_epoch: u64,
|
|
||||||
current_epoch: u64,
|
|
||||||
available: &mut bool,
|
|
||||||
samples: &mut HashMap<String, VecDeque<(f64, f64)>>,
|
|
||||||
) -> Option<Vec<u64>> {
|
|
||||||
*state = SlotState::Free;
|
|
||||||
match values {
|
|
||||||
None => {
|
|
||||||
*available = false;
|
|
||||||
samples.clear();
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Some(_) if !*available || completion_epoch != current_epoch => None,
|
|
||||||
Some(values) => Some(values),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel_state(state: &mut SlotState) {
|
|
||||||
if *state == SlotState::Encoding {
|
|
||||||
*state = SlotState::Free;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn validate(start: u64, end: u64, period: f64) -> Option<f64> {
|
|
||||||
if (start == 0 && end == 0) || end < start || !period.is_finite() || period <= 0.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let ms = (end - start) as f64 * period / 1_000_000.0;
|
|
||||||
if ms.is_finite() && ms >= 0.0 && ms <= 1000.0 {
|
|
||||||
Some(ms)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn feature_gate() {
|
|
||||||
assert!(Profiler::requested_features(false, wgpu::Features::TIMESTAMP_QUERY).is_empty());
|
|
||||||
assert!(Profiler::requested_features(true, wgpu::Features::empty()).is_empty());
|
|
||||||
assert_eq!(
|
|
||||||
Profiler::requested_features(true, wgpu::Features::TIMESTAMP_QUERY),
|
|
||||||
wgpu::Features::TIMESTAMP_QUERY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn validation() {
|
|
||||||
assert_eq!(validate(1, 2, 1_000_000.0), Some(1.0));
|
|
||||||
assert_eq!(validate(2, 2, 1.0), Some(0.0));
|
|
||||||
assert_eq!(validate(0, 0, 1.0), None);
|
|
||||||
assert_eq!(validate(2, 1, 1.0), None);
|
|
||||||
assert_eq!(validate(0, 1, 1_000_000_000.0), Some(1000.0));
|
|
||||||
assert_eq!(validate(0, 2, 1_000_000_000.0), None);
|
|
||||||
assert_eq!(validate(1, 2, f64::NAN), None);
|
|
||||||
assert_eq!(validate(1, 2, f64::INFINITY), None);
|
|
||||||
assert_eq!(validate(1, 2, 0.0), None);
|
|
||||||
assert_eq!(validate(1, 2, -1.0), None);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn capacity_is_aligned() {
|
|
||||||
assert_eq!(RESOLVE_SIZE % wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, 0);
|
|
||||||
assert!(RESOLVE_SIZE >= USED_RESOLVE_SIZE);
|
|
||||||
assert!(RESOLVE_SIZE - USED_RESOLVE_SIZE < wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT);
|
|
||||||
assert_eq!(QUERY_COUNT as usize, MAX_PROFILE_PASSES * 2)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn lazy_identity_when_disabled_unavailable_or_full() {
|
|
||||||
let mut calls = 0;
|
|
||||||
for (enabled, available) in [(false, true), (true, false)] {
|
|
||||||
assert_eq!(
|
|
||||||
profile_gate(enabled, available, || {
|
|
||||||
calls += 1;
|
|
||||||
Some(7)
|
|
||||||
}),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(calls, 0);
|
|
||||||
let mut full = [SlotState::Mapping; SLOT_COUNT];
|
|
||||||
assert_eq!(
|
|
||||||
profile_gate(true, true, || {
|
|
||||||
begin_transition(&mut full).map(|slot| {
|
|
||||||
calls += 1;
|
|
||||||
slot
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(calls, 0);
|
|
||||||
assert_eq!(full, [SlotState::Mapping; SLOT_COUNT]);
|
|
||||||
let mut states = [SlotState::Mapping, SlotState::Free];
|
|
||||||
assert_eq!(
|
|
||||||
profile_gate(true, true, || {
|
|
||||||
begin_transition(&mut states).map(|slot| {
|
|
||||||
calls += 1;
|
|
||||||
slot
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
assert_eq!(calls, 1);
|
|
||||||
assert_eq!(states, [SlotState::Mapping, SlotState::Encoding]);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn compact_ids_and_query_pairs_resolve_four() {
|
|
||||||
let mut ids = Vec::new();
|
|
||||||
let mut invalid = false;
|
|
||||||
assert_eq!(allocate_id(&mut ids, &mut invalid, "a"), Some(0));
|
|
||||||
assert_eq!(allocate_id(&mut ids, &mut invalid, "b"), Some(2));
|
|
||||||
assert_eq!(ids, ["a", "b"]);
|
|
||||||
let mut state = SlotState::Encoding;
|
|
||||||
assert_eq!(
|
|
||||||
finish_transition(&mut state, invalid, ids.len()),
|
|
||||||
FinishAction::Resolve(4)
|
|
||||||
);
|
|
||||||
assert!(!invalid);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn capacity_overflow_marks_frame_invalid() {
|
|
||||||
let mut ids = (0..MAX_PROFILE_PASSES).map(|i| i.to_string()).collect();
|
|
||||||
let mut invalid = false;
|
|
||||||
assert_eq!(allocate_id(&mut ids, &mut invalid, "overflow"), None);
|
|
||||||
assert!(invalid);
|
|
||||||
assert_eq!(ids.len(), MAX_PROFILE_PASSES);
|
|
||||||
let mut overflow = SlotState::Encoding;
|
|
||||||
assert_eq!(
|
|
||||||
finish_transition(&mut overflow, invalid, ids.len()),
|
|
||||||
FinishAction::Cancel
|
|
||||||
);
|
|
||||||
assert_eq!(overflow, SlotState::Free);
|
|
||||||
let mut empty = SlotState::Encoding;
|
|
||||||
assert_eq!(
|
|
||||||
finish_transition(&mut empty, false, 0),
|
|
||||||
FinishAction::Cancel
|
|
||||||
);
|
|
||||||
assert_eq!(empty, SlotState::Free);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn repeated_cancel_and_mapping_guard() {
|
|
||||||
let mut encoding = SlotState::Encoding;
|
|
||||||
for _ in 0..=SLOT_COUNT {
|
|
||||||
cancel_state(&mut encoding);
|
|
||||||
assert_eq!(encoding, SlotState::Free);
|
|
||||||
encoding = SlotState::Encoding;
|
|
||||||
}
|
|
||||||
let mut mapping = SlotState::Mapping;
|
|
||||||
cancel_state(&mut mapping);
|
|
||||||
assert_eq!(mapping, SlotState::Mapping);
|
|
||||||
let mut free = SlotState::Free;
|
|
||||||
cancel_state(&mut free);
|
|
||||||
assert_eq!(free, SlotState::Free);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn stale_epoch_map_failure_is_terminal_and_prevents_later_aggregation() {
|
|
||||||
let mut state = SlotState::Mapping;
|
|
||||||
let mut available = true;
|
|
||||||
let mut samples = HashMap::from([
|
|
||||||
("a".into(), VecDeque::from([(1.0, 2.0)])),
|
|
||||||
("b".into(), VecDeque::from([(3.0, 4.0)])),
|
|
||||||
]);
|
|
||||||
assert_eq!(
|
|
||||||
completion_transition(&mut state, None, 1, 2, &mut available, &mut samples),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(state, SlotState::Free);
|
|
||||||
assert!(!available);
|
|
||||||
assert!(samples.is_empty());
|
|
||||||
for epoch in [2, 1] {
|
|
||||||
state = SlotState::Mapping;
|
|
||||||
assert_eq!(
|
|
||||||
completion_transition(
|
|
||||||
&mut state,
|
|
||||||
Some(vec![1, 2]),
|
|
||||||
epoch,
|
|
||||||
2,
|
|
||||||
&mut available,
|
|
||||||
&mut samples
|
|
||||||
),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(state, SlotState::Free);
|
|
||||||
assert!(samples.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn snapshot_gate_is_silent_when_disabled_or_unavailable() {
|
|
||||||
for enabled in [false, true] {
|
|
||||||
for available in [false, true] {
|
|
||||||
assert_eq!(
|
|
||||||
profile_gate(enabled, available, || Some("snapshot")),
|
|
||||||
(enabled && available).then_some("snapshot")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
use wgpu::util::DeviceExt;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
camera::Camera,
|
|
||||||
render_data::RenderData,
|
|
||||||
renderer::{self, PipelineLibrary},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct UniformResource {
|
|
||||||
pub buffer: wgpu::Buffer,
|
|
||||||
pub bind_group: wgpu::BindGroup,
|
|
||||||
pub bind_group_layout: wgpu::BindGroupLayout,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
|
|
||||||
pub struct FrameMetadata {
|
|
||||||
pub mouse_move: [f32; 2],
|
|
||||||
pub mouse_click: [f32; 2],
|
|
||||||
pub resolution: [f32; 2],
|
|
||||||
time: f32,
|
|
||||||
_padding0: f32,
|
|
||||||
pub camera_position: [f32; 4],
|
|
||||||
}
|
|
||||||
impl FrameMetadata {
|
|
||||||
pub fn new(dimension: ultraviolet::Vec2) -> Self {
|
|
||||||
Self {
|
|
||||||
resolution: dimension.into(),
|
|
||||||
mouse_move: [f32::MIN; 2],
|
|
||||||
mouse_click: [f32::MIN; 2],
|
|
||||||
camera_position: [0., 0., 0., 1.],
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
|
|
||||||
self.camera_position = [p.x, p.y, p.z, 1.];
|
|
||||||
}
|
|
||||||
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
|
|
||||||
self.resolution = d.into();
|
|
||||||
}
|
|
||||||
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
|
|
||||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
||||||
label: Some("frame metadata"),
|
|
||||||
contents: bytemuck::bytes_of(&self),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
});
|
|
||||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
||||||
label: Some("frame layout"),
|
|
||||||
entries: &[wgpu::BindGroupLayoutEntry {
|
|
||||||
binding: 0,
|
|
||||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
|
||||||
ty: wgpu::BindingType::Buffer {
|
|
||||||
ty: wgpu::BufferBindingType::Uniform,
|
|
||||||
has_dynamic_offset: false,
|
|
||||||
min_binding_size: None,
|
|
||||||
},
|
|
||||||
count: None,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("frame group"),
|
|
||||||
layout: &bind_group_layout,
|
|
||||||
entries: &[wgpu::BindGroupEntry {
|
|
||||||
binding: 0,
|
|
||||||
resource: buffer.as_entire_binding(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
UniformResource {
|
|
||||||
buffer,
|
|
||||||
bind_group,
|
|
||||||
bind_group_layout,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait Scene: Sized {
|
|
||||||
fn setup(
|
|
||||||
context: &renderer::RendererContext,
|
|
||||||
resources: &mut PipelineLibrary,
|
|
||||||
data: &mut RenderData,
|
|
||||||
) -> Self;
|
|
||||||
fn bind_groups(&self) -> &[wgpu::BindGroup];
|
|
||||||
fn handle_mouse_click(&mut self, x: f32, y: f32);
|
|
||||||
fn handle_zoom(&mut self, delta_y: f32);
|
|
||||||
fn handle_orbit(&mut self, dx: f32, dy: f32);
|
|
||||||
fn handle_pan(&mut self, dx: f32, dy: f32, viewport_height: f32);
|
|
||||||
fn set_camera_depth_range(&mut self, near: f32, far: f32);
|
|
||||||
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
|
|
||||||
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
fn camera_mut(&mut self) -> Option<&mut Camera> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
|
|
||||||
self.camera_mut().map(|camera| camera.frustum_planes())
|
|
||||||
}
|
|
||||||
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
fn resize(&mut self, width: f64, height: f64, _: f64, queue: &wgpu::Queue) {
|
|
||||||
if let Some(f) = self.frame_metadata_mut() {
|
|
||||||
f.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
|
|
||||||
}
|
|
||||||
if let Some(c) = self.camera_mut() {
|
|
||||||
c.update_aspect_ratio(width as f32 / height as f32)
|
|
||||||
}
|
|
||||||
self.write_uniforms(queue);
|
|
||||||
}
|
|
||||||
fn update_cpu(&mut self) {
|
|
||||||
let position = match self.camera_mut() {
|
|
||||||
Some(c) => c.position(),
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
if let Some(f) = self.frame_metadata_mut() {
|
|
||||||
f.time = js_sys::Date::now() as f32 * 0.001;
|
|
||||||
f.set_camera_position(position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
|
|
||||||
let frame = self.frame_metadata_mut().copied();
|
|
||||||
let view = self.camera_mut().map(|c| c.view_proj);
|
|
||||||
if let (Some(f), Some(v), Some([frame_buffer, camera_buffer])) =
|
|
||||||
(frame, view, self.uniform_buffers())
|
|
||||||
{
|
|
||||||
queue.write_buffer(frame_buffer, 0, bytemuck::bytes_of(&f));
|
|
||||||
queue.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+232
-7
@@ -8,7 +8,11 @@ use std::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::render_data::{InstanceHandle, RenderData, RenderDataCapacities};
|
use crate::render_data::{
|
||||||
|
camera::Camera,
|
||||||
|
upload::{Material, MaterialState},
|
||||||
|
InstanceHandle, MaterialKey, RenderData, RenderDataCapacities,
|
||||||
|
};
|
||||||
|
|
||||||
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSOA");
|
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSOA");
|
||||||
pub const VERSION: u32 = 1;
|
pub const VERSION: u32 = 1;
|
||||||
@@ -344,6 +348,8 @@ fn valid_name(value: &str) -> bool {
|
|||||||
pub struct SharedSoaRegistry {
|
pub struct SharedSoaRegistry {
|
||||||
arrays: BTreeMap<String, SharedArray>,
|
arrays: BTreeMap<String, SharedArray>,
|
||||||
next_id: u32,
|
next_id: u32,
|
||||||
|
layout_changed: bool,
|
||||||
|
material_keys: Vec<MaterialKey>,
|
||||||
published_instance_generations: BTreeMap<u32, u32>,
|
published_instance_generations: BTreeMap<u32, u32>,
|
||||||
published_mesh_generations: BTreeMap<u32, u32>,
|
published_mesh_generations: BTreeMap<u32, u32>,
|
||||||
}
|
}
|
||||||
@@ -353,6 +359,8 @@ impl SharedSoaRegistry {
|
|||||||
let mut registry = Self {
|
let mut registry = Self {
|
||||||
arrays: BTreeMap::new(),
|
arrays: BTreeMap::new(),
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
|
layout_changed: false,
|
||||||
|
material_keys: Vec::new(),
|
||||||
published_instance_generations: BTreeMap::new(),
|
published_instance_generations: BTreeMap::new(),
|
||||||
published_mesh_generations: BTreeMap::new(),
|
published_mesh_generations: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
@@ -389,9 +397,28 @@ impl SharedSoaRegistry {
|
|||||||
stride: Some(16),
|
stride: Some(16),
|
||||||
length: None,
|
length: None,
|
||||||
},
|
},
|
||||||
|
ArrayRequest {
|
||||||
|
name: "camera.state".into(),
|
||||||
|
domain: ArrayDomain::Fixed,
|
||||||
|
scalar: ScalarType::F32,
|
||||||
|
lanes: 16,
|
||||||
|
stride: Some(64),
|
||||||
|
length: Some(1),
|
||||||
|
},
|
||||||
|
ArrayRequest {
|
||||||
|
name: "material.state".into(),
|
||||||
|
domain: ArrayDomain::Fixed,
|
||||||
|
scalar: ScalarType::U32,
|
||||||
|
lanes: MaterialState::LANES,
|
||||||
|
stride: Some(112),
|
||||||
|
length: Some(1),
|
||||||
|
},
|
||||||
] {
|
] {
|
||||||
registry.allocate(request, capacities)?;
|
registry.allocate(request, capacities)?;
|
||||||
}
|
}
|
||||||
|
registry.publish_materials(&[Material::default()])?;
|
||||||
|
registry.material_keys.clear();
|
||||||
|
registry.layout_changed = false;
|
||||||
Ok(registry)
|
Ok(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +474,7 @@ impl SharedSoaRegistry {
|
|||||||
return Err(SharedSoaError::LayoutConflict);
|
return Err(SharedSoaError::LayoutConflict);
|
||||||
}
|
}
|
||||||
if request.domain == ArrayDomain::Fixed {
|
if request.domain == ArrayDomain::Fixed {
|
||||||
existing.resize(capacity)?;
|
self.layout_changed |= existing.resize(capacity)?;
|
||||||
}
|
}
|
||||||
return existing.descriptor();
|
return existing.descriptor();
|
||||||
}
|
}
|
||||||
@@ -460,6 +487,7 @@ impl SharedSoaRegistry {
|
|||||||
let array = SharedArray::new(id, request, stride / 4, capacity)?;
|
let array = SharedArray::new(id, request, stride / 4, capacity)?;
|
||||||
let descriptor = array.descriptor()?;
|
let descriptor = array.descriptor()?;
|
||||||
self.arrays.insert(name, array);
|
self.arrays.insert(name, array);
|
||||||
|
self.layout_changed = true;
|
||||||
Ok(descriptor)
|
Ok(descriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,12 +542,132 @@ impl SharedSoaRegistry {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish an infrequent worker-owned camera reset into the canonical shared row.
|
||||||
|
pub fn publish_camera(&mut self, camera: &Camera) -> Result<(), SharedSoaError> {
|
||||||
|
let array = self
|
||||||
|
.arrays
|
||||||
|
.get_mut("camera.state")
|
||||||
|
.ok_or(SharedSoaError::UnknownArray)?;
|
||||||
|
let sequence = array.try_lock().ok_or(SharedSoaError::Busy)?;
|
||||||
|
for (lane, value) in camera.shared_state().into_iter().enumerate() {
|
||||||
|
array
|
||||||
|
.data_word(0, lane as u32)
|
||||||
|
.store(value.to_bits(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
array.unlock(sequence);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a newly published shared camera row. Invalid external rows are replaced
|
||||||
|
/// with the last valid worker state so all writers can recover on their next read.
|
||||||
|
pub fn synchronize_camera(&mut self, camera: &mut Camera) -> Result<(), SharedSoaError> {
|
||||||
|
let Some(array) = self.arrays.get_mut("camera.state") else {
|
||||||
|
return Err(SharedSoaError::UnknownArray);
|
||||||
|
};
|
||||||
|
if !array.changed() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let sequence = array.try_lock().ok_or(SharedSoaError::Busy)?;
|
||||||
|
let state = std::array::from_fn(|lane| {
|
||||||
|
f32::from_bits(array.data_word(0, lane as u32).load(Ordering::Acquire))
|
||||||
|
});
|
||||||
|
array.unlock(sequence);
|
||||||
|
if !camera.apply_shared_state(state) {
|
||||||
|
self.publish_camera(camera)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the packed material rows after a transactional render-data upload.
|
||||||
|
pub fn publish_materials(&mut self, materials: &[Material]) -> Result<(), SharedSoaError> {
|
||||||
|
let length = materials
|
||||||
|
.iter()
|
||||||
|
.map(|material| material.key.get())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(SharedSoaError::SizeOverflow)?;
|
||||||
|
self.allocate(
|
||||||
|
ArrayRequest {
|
||||||
|
name: "material.state".into(),
|
||||||
|
domain: ArrayDomain::Fixed,
|
||||||
|
scalar: ScalarType::U32,
|
||||||
|
lanes: MaterialState::LANES,
|
||||||
|
stride: Some(112),
|
||||||
|
length: Some(length),
|
||||||
|
},
|
||||||
|
RenderDataCapacities {
|
||||||
|
vertices: 0,
|
||||||
|
indices: 0,
|
||||||
|
meshes: 0,
|
||||||
|
instances: 0,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let array = self
|
||||||
|
.arrays
|
||||||
|
.get_mut("material.state")
|
||||||
|
.ok_or(SharedSoaError::UnknownArray)?;
|
||||||
|
let sequence = (0..1024)
|
||||||
|
.find_map(|_| array.try_lock())
|
||||||
|
.ok_or(SharedSoaError::Busy)?;
|
||||||
|
let fallback = MaterialState::from(&Material::default()).words();
|
||||||
|
for slot in 0..length {
|
||||||
|
for (lane, word) in fallback.iter().copied().enumerate() {
|
||||||
|
array
|
||||||
|
.data_word(slot, lane as u32)
|
||||||
|
.store(word, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for material in materials {
|
||||||
|
for (lane, word) in MaterialState::from(material)
|
||||||
|
.words()
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
array
|
||||||
|
.data_word(material.key.get(), lane as u32)
|
||||||
|
.store(word, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
array.unlock(sequence);
|
||||||
|
self.material_keys = materials.iter().map(|material| material.key).collect();
|
||||||
|
self.material_keys.sort_by_key(|key| key.get());
|
||||||
|
self.material_keys.dedup();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes complete changed material rows for one batched queue write per material.
|
||||||
|
pub fn take_material_words(
|
||||||
|
&mut self,
|
||||||
|
) -> Option<Vec<(MaterialKey, [u32; MaterialState::LANES as usize])>> {
|
||||||
|
let array = self.arrays.get_mut("material.state")?;
|
||||||
|
if !array.changed() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sequence = array.try_lock()?;
|
||||||
|
let rows = self
|
||||||
|
.material_keys
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(|key| {
|
||||||
|
let words = std::array::from_fn(|lane| {
|
||||||
|
array
|
||||||
|
.data_word(key.get(), lane as u32)
|
||||||
|
.load(Ordering::Acquire)
|
||||||
|
});
|
||||||
|
(key, words)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
array.unlock(sequence);
|
||||||
|
Some(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reallocates matching-domain columns before a frame. Old blocks stay pinned.
|
/// Reallocates matching-domain columns before a frame. Old blocks stay pinned.
|
||||||
pub fn sync_capacities(
|
pub fn sync_capacities(
|
||||||
&mut self,
|
&mut self,
|
||||||
capacities: RenderDataCapacities,
|
capacities: RenderDataCapacities,
|
||||||
) -> Result<bool, SharedSoaError> {
|
) -> Result<bool, SharedSoaError> {
|
||||||
let mut changed = false;
|
let mut changed = std::mem::take(&mut self.layout_changed);
|
||||||
for array in self.arrays.values_mut() {
|
for array in self.arrays.values_mut() {
|
||||||
if array.request.domain == ArrayDomain::Fixed {
|
if array.request.domain == ArrayDomain::Fixed {
|
||||||
continue;
|
continue;
|
||||||
@@ -718,6 +866,83 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_is_one_aligned_row_and_external_updates_are_validated() {
|
||||||
|
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
|
||||||
|
let descriptor = registry.arrays["camera.state"].descriptor().unwrap();
|
||||||
|
assert_eq!(descriptor.domain, ArrayDomain::Fixed);
|
||||||
|
assert_eq!(descriptor.scalar, ScalarType::F32);
|
||||||
|
assert_eq!(
|
||||||
|
(descriptor.lanes, descriptor.stride, descriptor.length),
|
||||||
|
(16, 64, 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut camera = Camera::new(1.5);
|
||||||
|
registry.publish_camera(&camera).unwrap();
|
||||||
|
let mut external = camera.shared_state();
|
||||||
|
external[0..3].copy_from_slice(&[2.0, 1.0, 4.0]);
|
||||||
|
external[13] = 2.0;
|
||||||
|
let array = registry.arrays.get_mut("camera.state").unwrap();
|
||||||
|
let sequence = array.word(9).load(Ordering::Acquire);
|
||||||
|
array.word(9).store(sequence + 1, Ordering::Release);
|
||||||
|
for (lane, value) in external.into_iter().enumerate() {
|
||||||
|
array
|
||||||
|
.data_word(0, lane as u32)
|
||||||
|
.store(value.to_bits(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
array.word(9).store(sequence + 2, Ordering::Release);
|
||||||
|
|
||||||
|
registry.synchronize_camera(&mut camera).unwrap();
|
||||||
|
assert_eq!(camera.shared_state(), external);
|
||||||
|
|
||||||
|
let valid = camera.shared_state();
|
||||||
|
let array = registry.arrays.get_mut("camera.state").unwrap();
|
||||||
|
let sequence = array.word(9).load(Ordering::Acquire);
|
||||||
|
array.word(9).store(sequence + 1, Ordering::Release);
|
||||||
|
array
|
||||||
|
.data_word(0, 13)
|
||||||
|
.store(f32::NAN.to_bits(), Ordering::Relaxed);
|
||||||
|
array.word(9).store(sequence + 2, Ordering::Release);
|
||||||
|
registry.synchronize_camera(&mut camera).unwrap();
|
||||||
|
let recovered = registry.arrays["camera.state"].data_word(0, 13);
|
||||||
|
assert_eq!(f32::from_bits(recovered.load(Ordering::Acquire)), valid[13]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn material_rows_are_packed_resized_and_consumed_after_external_writes() {
|
||||||
|
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
|
||||||
|
let mut material = Material {
|
||||||
|
key: MaterialKey::new(3),
|
||||||
|
..Material::default()
|
||||||
|
};
|
||||||
|
material.base_color_factor = [0.2, 0.4, 0.8, 1.0];
|
||||||
|
material.roughness_factor = 0.75;
|
||||||
|
registry.publish_materials(&[material.clone()]).unwrap();
|
||||||
|
let descriptor = registry.arrays["material.state"].descriptor().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
descriptor.scalar,
|
||||||
|
descriptor.lanes,
|
||||||
|
descriptor.stride,
|
||||||
|
descriptor.length
|
||||||
|
),
|
||||||
|
(ScalarType::U32, 28, 112, 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
let array = registry.arrays.get_mut("material.state").unwrap();
|
||||||
|
let sequence = array.word(9).load(Ordering::Acquire);
|
||||||
|
array.word(9).store(sequence + 1, Ordering::Release);
|
||||||
|
array
|
||||||
|
.data_word(3, 9)
|
||||||
|
.store(0.25f32.to_bits(), Ordering::Relaxed);
|
||||||
|
array.word(9).store(sequence + 2, Ordering::Release);
|
||||||
|
let rows = registry.take_material_words().unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].0, MaterialKey::new(3));
|
||||||
|
assert_eq!(f32::from_bits(rows[0].1[9]), 0.25);
|
||||||
|
assert!(registry.take_material_words().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_layouts_are_aligned_idempotent_and_conflict_checked() {
|
fn custom_layouts_are_aligned_idempotent_and_conflict_checked() {
|
||||||
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
|
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
|
||||||
@@ -761,7 +986,7 @@ mod tests {
|
|||||||
fn fixed_arrays_grow_and_publish_stable_byte_uploads() {
|
fn fixed_arrays_grow_and_publish_stable_byte_uploads() {
|
||||||
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
|
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
|
||||||
let request = |length| ArrayRequest {
|
let request = |length| ArrayRequest {
|
||||||
name: "upload.gltf".into(),
|
name: "upload.renderData".into(),
|
||||||
domain: ArrayDomain::Fixed,
|
domain: ArrayDomain::Fixed,
|
||||||
scalar: ScalarType::U32,
|
scalar: ScalarType::U32,
|
||||||
lanes: 4,
|
lanes: 4,
|
||||||
@@ -773,18 +998,18 @@ mod tests {
|
|||||||
assert_eq!(first.id, second.id);
|
assert_eq!(first.id, second.id);
|
||||||
assert_eq!((second.length, second.capacity), (2, 2));
|
assert_eq!((second.length, second.capacity), (2, 2));
|
||||||
|
|
||||||
let array = registry.arrays.get_mut("upload.gltf").unwrap();
|
let array = registry.arrays.get_mut("upload.renderData").unwrap();
|
||||||
let sequence = array.try_lock().unwrap();
|
let sequence = array.try_lock().unwrap();
|
||||||
array
|
array
|
||||||
.data_word(0, 0)
|
.data_word(0, 0)
|
||||||
.store(u32::from_le_bytes(*b"glTF"), Ordering::Relaxed);
|
.store(u32::from_le_bytes(*b"YRDP"), Ordering::Relaxed);
|
||||||
array
|
array
|
||||||
.data_word(0, 1)
|
.data_word(0, 1)
|
||||||
.store(u32::from_le_bytes([2, 0, 0, 0]), Ordering::Relaxed);
|
.store(u32::from_le_bytes([2, 0, 0, 0]), Ordering::Relaxed);
|
||||||
array.unlock(sequence);
|
array.unlock(sequence);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.read_fixed_bytes(first.id, 8).unwrap(),
|
registry.read_fixed_bytes(first.id, 8).unwrap(),
|
||||||
b"glTF\x02\0\0\0"
|
b"YRDP\x02\0\0\0"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ dir = "my-template"
|
|||||||
#! When there is only `name`, other fields will use the default configuration
|
#! When there is only `name`, other fields will use the default configuration
|
||||||
|
|
||||||
[[crates]]
|
[[crates]]
|
||||||
name = "level-editor"
|
name = "renderer"
|
||||||
root = "."
|
root = "."
|
||||||
out-dir = "pkg"
|
out-dir = "pkg"
|
||||||
target = "web"
|
target = "web"
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ import {
|
|||||||
createVelocityColumn,
|
createVelocityColumn,
|
||||||
customRenderPipelineExample,
|
customRenderPipelineExample,
|
||||||
defaultPipelineExample,
|
defaultPipelineExample,
|
||||||
dropActiveGraph,
|
|
||||||
fluentGraphExample,
|
fluentGraphExample,
|
||||||
fxNodeExportExample,
|
fxNodeExportExample,
|
||||||
importGltf,
|
importGltf,
|
||||||
|
installCameraRenderDataControls,
|
||||||
jsoGraphExample,
|
jsoGraphExample,
|
||||||
loadCompleteScene,
|
loadCompleteScene,
|
||||||
pickNearest,
|
pickNearest,
|
||||||
|
conventionalSceneHandles,
|
||||||
|
restyleScene,
|
||||||
setVelocity,
|
setVelocity,
|
||||||
simpleSceneShader,
|
simpleSceneShader,
|
||||||
updateInstance,
|
updateInstance,
|
||||||
@@ -63,7 +65,7 @@ test("cookbook graph recipes produce canonical addon-owned ASTs", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cookbook graph lifecycle recipe serializes, switches, and drops", async () => {
|
test("cookbook graph lifecycle recipe serializes and switches", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const core = {
|
const core = {
|
||||||
compileGraph(source) {
|
compileGraph(source) {
|
||||||
@@ -74,10 +76,6 @@ test("cookbook graph lifecycle recipe serializes, switches, and drops", async ()
|
|||||||
calls.push(["switch", id]);
|
calls.push(["switch", id]);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
switchToImmediate() {
|
|
||||||
calls.push(["immediate"]);
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
dropCompiledGraph(id) {
|
dropCompiledGraph(id) {
|
||||||
calls.push(["drop", id]);
|
calls.push(["drop", id]);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -85,13 +83,8 @@ test("cookbook graph lifecycle recipe serializes, switches, and drops", async ()
|
|||||||
};
|
};
|
||||||
const graph = { id: "lifecycle", revision: 1, nodes: [] };
|
const graph = { id: "lifecycle", revision: 1, nodes: [] };
|
||||||
const compiled = await compileAndSwitch(core, graph);
|
const compiled = await compileAndSwitch(core, graph);
|
||||||
await dropActiveGraph(core, compiled);
|
|
||||||
assert.match(calls[0][1], /^\(yawn-graph 1/);
|
assert.match(calls[0][1], /^\(yawn-graph 1/);
|
||||||
assert.deepEqual(calls.slice(1), [
|
assert.deepEqual(calls.slice(1), [["switch", [3, 4]]]);
|
||||||
["switch", [3, 4]],
|
|
||||||
["immediate"],
|
|
||||||
["drop", [3, 4]],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cookbook mutation recipes use handles and SOA writes directly", async () => {
|
test("cookbook mutation recipes use handles and SOA writes directly", async () => {
|
||||||
@@ -163,7 +156,7 @@ test("cookbook picking and worker-to-worker recipes use public facades", async (
|
|||||||
[0, 0, 0],
|
[0, 0, 0],
|
||||||
[0, 0, -1],
|
[0, 0, -1],
|
||||||
);
|
);
|
||||||
assert.deepEqual(picked.hits[0].instance.handle, [9, 3]);
|
assert.deepEqual(picked.hits[0].instance, [9, 3]);
|
||||||
|
|
||||||
class Port extends EventTarget {
|
class Port extends EventTarget {
|
||||||
postMessage() {}
|
postMessage() {}
|
||||||
@@ -183,5 +176,8 @@ test("cookbook picking and worker-to-worker recipes use public facades", async (
|
|||||||
core.dispose();
|
core.dispose();
|
||||||
|
|
||||||
assert.equal(typeof importGltf, "function");
|
assert.equal(typeof importGltf, "function");
|
||||||
|
assert.equal(typeof installCameraRenderDataControls, "function");
|
||||||
|
assert.equal(typeof conventionalSceneHandles, "function");
|
||||||
|
assert.equal(typeof restyleScene, "function");
|
||||||
assert.equal(typeof loadCompleteScene, "function");
|
assert.equal(typeof loadCompleteScene, "function");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getU
|
|||||||
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}});
|
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}});
|
||||||
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
|
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
|
||||||
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
|
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
|
||||||
test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"PBR material gallery");});
|
test("PBR gallery is deterministic and covers the default material shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"PBR material gallery");});
|
||||||
test("the example offers only self-contained procedural scenes",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../examples/render-graph-studio/index.html",import.meta.url),"utf8");assert.deepEqual(Object.keys(loadouts),["cubes","spheres","materials"]);for(const id of Object.keys(loadouts))assert.match(html,new RegExp(`<option value="${id}">`));await assert.rejects(loadDemoLoadout("unknown"),RangeError);});
|
test("the example offers only self-contained procedural scenes",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../examples/render-graph-studio/index.html",import.meta.url),"utf8");assert.deepEqual(Object.keys(loadouts),["cubes","spheres","materials"]);for(const id of Object.keys(loadouts))assert.match(html,new RegExp(`<option value="${id}">`));await assert.rejects(loadDemoLoadout("unknown"),RangeError);});
|
||||||
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
|
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import test from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { GltfImporter } from "@yawn/gltf-import";
|
import { GltfImporter } from "@yawn/gltf-import";
|
||||||
|
import { gltfToRenderDataPacket } from "../addons/gltf-import/src/gltf.js";
|
||||||
import { writeSharedUpload } from "../addons/gltf-import/src/shared-upload.js";
|
import { writeSharedUpload } from "../addons/gltf-import/src/shared-upload.js";
|
||||||
|
import { createCubeGeometry, encodeGeometryGlb } from "../examples/render-graph-studio/demo-loadouts.js";
|
||||||
|
|
||||||
class WorkerMock extends EventTarget {
|
class WorkerMock extends EventTarget {
|
||||||
messages = [];
|
messages = [];
|
||||||
@@ -18,7 +20,7 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
|
|||||||
const memory = new SharedArrayBuffer(4096);
|
const memory = new SharedArrayBuffer(4096);
|
||||||
const descriptor = {
|
const descriptor = {
|
||||||
id: 9,
|
id: 9,
|
||||||
name: "upload.gltf",
|
name: "upload.renderData",
|
||||||
domain: "fixed",
|
domain: "fixed",
|
||||||
scalar: "u32",
|
scalar: "u32",
|
||||||
lanes: 4,
|
lanes: 4,
|
||||||
@@ -36,8 +38,8 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
|
|||||||
const calls = [];
|
const calls = [];
|
||||||
const core = {
|
const core = {
|
||||||
async allocateArray(layout) { calls.push(["allocate", layout]); return array; },
|
async allocateArray(layout) { calls.push(["allocate", layout]); return array; },
|
||||||
async commitGlbUpload(value, byteLength, options) {
|
async commitRenderDataUpload(value, byteLength) {
|
||||||
calls.push(["commit", value, byteLength, options]);
|
calls.push(["commit", value, byteLength]);
|
||||||
return { meshes: [] };
|
return { meshes: [] };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -54,7 +56,7 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
|
|||||||
worker.reply({ type: "allocate", request: 1, byteLength: 20 });
|
worker.reply({ type: "allocate", request: 1, byteLength: 20 });
|
||||||
await tick();
|
await tick();
|
||||||
assert.deepEqual(calls[0], ["allocate", {
|
assert.deepEqual(calls[0], ["allocate", {
|
||||||
name: "upload.gltf", domain: "fixed", scalar: "u32", lanes: 4, stride: 16, length: 2,
|
name: "upload.renderData", domain: "fixed", scalar: "u32", lanes: 4, stride: 16, length: 2,
|
||||||
}]);
|
}]);
|
||||||
assert.equal(worker.messages[1].buffer, memory);
|
assert.equal(worker.messages[1].buffer, memory);
|
||||||
assert.equal(worker.messages[1].descriptor, descriptor);
|
assert.equal(worker.messages[1].descriptor, descriptor);
|
||||||
@@ -65,7 +67,25 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
|
|||||||
worker.reply({ type: "ready", request: 1, byteLength: bytes.byteLength });
|
worker.reply({ type: "ready", request: 1, byteLength: bytes.byteLength });
|
||||||
assert.deepEqual(await loading, { meshes: [] });
|
assert.deepEqual(await loading, { meshes: [] });
|
||||||
assert.deepEqual(new Uint8Array(memory, 64, 20), bytes);
|
assert.deepEqual(new Uint8Array(memory, 64, 20), bytes);
|
||||||
assert.deepEqual(calls[1], ["commit", array, 20, { framing: "interior" }]);
|
assert.deepEqual(calls[1], ["commit", array, 20]);
|
||||||
importer.dispose();
|
importer.dispose();
|
||||||
assert.equal(worker.terminated, true);
|
assert.equal(worker.terminated, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("glTF parsing produces a format-neutral typed render-data packet in the addon", async () => {
|
||||||
|
const glb = encodeGeometryGlb(createCubeGeometry());
|
||||||
|
const packet = await gltfToRenderDataPacket(
|
||||||
|
new Uint8Array(glb),
|
||||||
|
"https://example.test/scene.glb",
|
||||||
|
);
|
||||||
|
const header = new DataView(packet.buffer, packet.byteOffset, packet.byteLength);
|
||||||
|
assert.equal(header.getUint32(0, true), 0x50445259);
|
||||||
|
assert.equal(header.getUint32(4, true), 1);
|
||||||
|
const metadataLength = header.getUint32(8, true);
|
||||||
|
const payloadLength = header.getUint32(12, true);
|
||||||
|
const metadata = JSON.parse(new TextDecoder().decode(packet.subarray(16, 16 + metadataLength)));
|
||||||
|
assert.equal(metadata.geometries.length, 1);
|
||||||
|
assert.equal(metadata.occurrences.length, 9);
|
||||||
|
assert.deepEqual(metadata.geometries[0].instanceType.slice(0, 2), [5, 0]);
|
||||||
|
assert.equal(((16 + metadataLength + 3) & ~3) + payloadLength, packet.byteLength);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { SnapshotReader, SnapshotProtocolError } from "../packages/yawn-core/src/snapshot.js";
|
import { SnapshotReader, SnapshotProtocolError } from "../addons/mesh-handles/src/snapshot.js";
|
||||||
import { DerivedBvh } from "../addons/mesh-handles/src/bvh-core.js";
|
import { DerivedBvh } from "../addons/mesh-handles/src/bvh-core.js";
|
||||||
|
import { MeshHandles } from "../addons/mesh-handles/src/index.js";
|
||||||
import { YawnCore } from "../packages/yawn-core/src/index.js";
|
import { YawnCore } from "../packages/yawn-core/src/index.js";
|
||||||
|
|
||||||
const align16 = value => (value + 15) & ~15;
|
const align16 = value => (value + 15) & ~15;
|
||||||
@@ -108,25 +109,27 @@ class WorkerMock extends EventTarget {
|
|||||||
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
|
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("core picking returns protocol handles and exact epoch", async () => {
|
test("mesh addon owns picking and returns wrapped handles at the exact epoch", async () => {
|
||||||
const scene = snapshotFixture();
|
const scene = snapshotFixture();
|
||||||
const ring = 8192;
|
const ring = 8192;
|
||||||
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16);
|
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16);
|
||||||
ringHeader.set([0x4e574159, 2, 1024, 40]);
|
ringHeader.set([0x4e574159, 2, 1024, 40]);
|
||||||
const rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock();
|
const rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock();
|
||||||
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, pickingWorkerFactory: () => bvhWorker, free() {} };
|
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, free() {} };
|
||||||
const client = new YawnCore(bridge);
|
const client = new YawnCore(bridge);
|
||||||
|
const handles = new MeshHandles(client, { pickingWorkerFactory: () => bvhWorker });
|
||||||
rendererWorker.reply({ type: "soa-init", arrays: [] });
|
rendererWorker.reply({ type: "soa-init", arrays: [] });
|
||||||
await client.ready;
|
await client.ready;
|
||||||
rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 2 });
|
rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 2 });
|
||||||
rendererWorker.reply({ type: "snapshot-published", epoch: 1 });
|
rendererWorker.reply({ type: "snapshot-published", epoch: 1 });
|
||||||
const picking = client.pickRay([0, 0, 0], [1, 0, 0]);
|
const picking = handles.pickRay([0, 0, 0], [1, 0, 0]);
|
||||||
const request = bvhWorker.messages.find(message => message.type === "pick");
|
const request = bvhWorker.messages.find(message => message.type === "pick");
|
||||||
bvhWorker.reply({ type: "pick", request: request.request, epoch: 1, stale: false, hits: [{ slot: 10, generation: 3, distance: 2 }] });
|
bvhWorker.reply({ type: "pick", request: request.request, epoch: 1, stale: false, hits: [{ slot: 10, generation: 3, distance: 2 }] });
|
||||||
const result = await picking;
|
const result = await picking;
|
||||||
assert.equal(result.epoch, 1);
|
assert.equal(result.epoch, 1);
|
||||||
assert.equal(result.hits[0].distance, 2);
|
assert.equal(result.hits[0].distance, 2);
|
||||||
assert.deepEqual(result.hits[0].instance, [10, 3]);
|
assert.deepEqual(result.hits[0].instance.handle, [10, 3]);
|
||||||
|
handles.dispose();
|
||||||
client.dispose();
|
client.dispose();
|
||||||
assert.equal(bvhWorker.terminated, true);
|
assert.equal(bvhWorker.terminated, true);
|
||||||
});
|
});
|
||||||
|
|||||||
+80
-20
@@ -1,10 +1,12 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { YawnCore, RendererError } from "@yawn/core";
|
import * as coreApi from "@yawn/core";
|
||||||
import { MeshHandles } from "@yawn/mesh-handles";
|
import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
|
||||||
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
|
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
|
||||||
|
|
||||||
|
const { YawnCore, RendererError } = coreApi;
|
||||||
|
|
||||||
const TYPE = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 0x80000000, 0xffffffff];
|
const TYPE = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 0x80000000, 0xffffffff];
|
||||||
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||||
|
|
||||||
@@ -70,19 +72,37 @@ function setup() {
|
|||||||
byteLength: 256, layoutEpoch: 1, writable: false,
|
byteLength: 256, layoutEpoch: 1, writable: false,
|
||||||
});
|
});
|
||||||
const upload = installArray(memory, {
|
const upload = installArray(memory, {
|
||||||
id: 5, name: "upload.gltf", domain: "fixed", scalar: "u32", lanes: 4,
|
id: 5, name: "upload.renderData", domain: "fixed", scalar: "u32", lanes: 4,
|
||||||
stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64,
|
stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64,
|
||||||
byteLength: 256, layoutEpoch: 1, writable: true,
|
byteLength: 256, layoutEpoch: 1, writable: true,
|
||||||
});
|
});
|
||||||
|
const camera = installArray(memory, {
|
||||||
|
id: 6, name: "camera.state", domain: "fixed", scalar: "f32", lanes: 16,
|
||||||
|
stride: 64, length: 1, capacity: 1, controlPtr: 200384, dataOffset: 64,
|
||||||
|
byteLength: 64, layoutEpoch: 1, writable: true,
|
||||||
|
});
|
||||||
|
new Float32Array(memory.buffer, camera.controlPtr + camera.dataOffset, 16).set([
|
||||||
|
4, 3, 6, 1,
|
||||||
|
0, 0, 0, 1,
|
||||||
|
0, 1, 0, 0,
|
||||||
|
Math.PI / 4, 16 / 9, 0.1, 1000,
|
||||||
|
]);
|
||||||
|
const material = installArray(memory, {
|
||||||
|
id: 7, name: "material.state", domain: "fixed", scalar: "u32", lanes: 28,
|
||||||
|
stride: 112, length: 4, capacity: 4, controlPtr: 200512, dataOffset: 64,
|
||||||
|
byteLength: 448, layoutEpoch: 1, writable: true,
|
||||||
|
});
|
||||||
|
const materialFloats = new Float32Array(memory.buffer, material.controlPtr + material.dataOffset, material.byteLength / 4);
|
||||||
|
materialFloats.set([1, 1, 1, 1, 0, 0, 0, 0, 1, 0.5, 1, 1, 0, 0.5, 1.5, 0.04], 28);
|
||||||
const worker = new WorkerMock();
|
const worker = new WorkerMock();
|
||||||
const bridge = { memory, ringPtr: 0, worker, freed: false, free() { this.freed = true; } };
|
const bridge = { memory, ringPtr: 0, worker, freed: false, free() { this.freed = true; } };
|
||||||
const core = new YawnCore(bridge);
|
const core = new YawnCore(bridge);
|
||||||
worker.reply({ type: "soa-init", arrays: [transform, type, generation, meshGeneration, upload] });
|
worker.reply({ type: "soa-init", arrays: [transform, type, generation, meshGeneration, upload, camera, material] });
|
||||||
return { memory, ring, worker, bridge, core, handles: new MeshHandles(core), transform, type, generation, upload };
|
return { memory, ring, worker, bridge, core, handles: new MeshHandles(core), transform, type, generation, upload, camera, material };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function imported(fixture) {
|
async function imported(fixture) {
|
||||||
const loading = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8);
|
const loading = fixture.core.commitRenderDataUpload(fixture.core.array("upload.renderData"), 8);
|
||||||
fixture.worker.reply({
|
fixture.worker.reply({
|
||||||
type: "reply",
|
type: "reply",
|
||||||
request: 1,
|
request: 1,
|
||||||
@@ -93,6 +113,7 @@ async function imported(fixture) {
|
|||||||
defaultInstance: [8, 5],
|
defaultInstance: [8, 5],
|
||||||
defaultType: TYPE,
|
defaultType: TYPE,
|
||||||
}],
|
}],
|
||||||
|
materials: [{ key: 1 }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [mesh] = fixture.handles.fromImportedScene(await loading);
|
const [mesh] = fixture.handles.fromImportedScene(await loading);
|
||||||
@@ -105,10 +126,10 @@ async function imported(fixture) {
|
|||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("core commits shared scene uploads through metadata-only opcode 1", async () => {
|
test("core commits generic shared render-data packets through metadata-only opcode 1", async () => {
|
||||||
const fixture = setup();
|
const fixture = setup();
|
||||||
const pending = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8, { framing: "interior" });
|
const pending = fixture.core.commitRenderDataUpload(fixture.core.array("upload.renderData"), 8);
|
||||||
assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 6)], [2, 1, 1, 5, 8, 1]);
|
assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 6)], [2, 1, 1, 5, 8, 0]);
|
||||||
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { meshes: [] } });
|
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { meshes: [] } });
|
||||||
assert.deepEqual(await pending, { meshes: [] });
|
assert.deepEqual(await pending, { meshes: [] });
|
||||||
});
|
});
|
||||||
@@ -127,17 +148,6 @@ test("mesh handles are a separate conventional facade over core commands", async
|
|||||||
assert.deepEqual(instance.handle, [4, 2]);
|
assert.deepEqual(instance.handle, [4, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mesh handle picking wraps core protocol handles", async () => {
|
|
||||||
const core = {
|
|
||||||
async pickRay() {
|
|
||||||
return { epoch: 4, hits: [{ instance: [3, 9], distance: 2 }] };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = await new MeshHandles(core).pickRay([0, 0, 0], [1, 0, 0]);
|
|
||||||
assert.deepEqual(result.hits[0].instance.handle, [3, 9]);
|
|
||||||
assert.equal(result.hits[0].distance, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("frequent instance mutations write guarded SOA lanes without ring messages", async () => {
|
test("frequent instance mutations write guarded SOA lanes without ring messages", async () => {
|
||||||
const fixture = setup();
|
const fixture = setup();
|
||||||
const mesh = await imported(fixture);
|
const mesh = await imported(fixture);
|
||||||
@@ -158,6 +168,55 @@ test("frequent instance mutations write guarded SOA lanes without ring messages"
|
|||||||
assert.equal(Atomics.load(type, base + 17) >>> 0, 1);
|
assert.equal(Atomics.load(type, base + 17) >>> 0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("camera is render data with no dedicated core API", () => {
|
||||||
|
const fixture = setup();
|
||||||
|
const camera = fixture.core.array("camera.state");
|
||||||
|
const ringBefore = Atomics.load(fixture.ring, 5);
|
||||||
|
const control = new Int32Array(fixture.memory.buffer, fixture.camera.controlPtr, 16);
|
||||||
|
const sequenceBefore = Atomics.load(control, 9);
|
||||||
|
const state = camera.read(0);
|
||||||
|
state[0] = 5;
|
||||||
|
state[1] = 4;
|
||||||
|
camera.write(0, state);
|
||||||
|
|
||||||
|
assert.equal("SharedCamera" in coreApi, false);
|
||||||
|
assert.deepEqual(camera.read(0).slice(0, 3), [5, 4, 6]);
|
||||||
|
assert.equal(Atomics.load(fixture.ring, 5), ringBefore);
|
||||||
|
assert.equal(Atomics.load(control, 9), sequenceBefore + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("camera handle exposes conventional properties using only the shared row", () => {
|
||||||
|
const fixture = setup();
|
||||||
|
const camera = new CameraHandle(fixture.core);
|
||||||
|
const ringBefore = Atomics.load(fixture.ring, 5);
|
||||||
|
|
||||||
|
camera.update({ position: [5, 4, 7], target: [1, 0, 0], fovY: Math.PI / 3 });
|
||||||
|
|
||||||
|
assert.deepEqual(camera.position, [5, 4, 7]);
|
||||||
|
assert.deepEqual(camera.target, [1, 0, 0]);
|
||||||
|
assert.ok(Math.abs(camera.fovY - Math.PI / 3) < 1e-6);
|
||||||
|
assert.equal(Atomics.load(fixture.ring, 5), ringBefore);
|
||||||
|
assert.throws(() => camera.update({ near: 2, far: 1 }), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("material handles mutate packed GPU material rows without messages", () => {
|
||||||
|
const fixture = setup();
|
||||||
|
const [material] = new MaterialHandles(fixture.core).fromImportedScene({ materials: [{ key: 1 }] });
|
||||||
|
const ringBefore = Atomics.load(fixture.ring, 5);
|
||||||
|
const control = new Int32Array(fixture.memory.buffer, fixture.material.controlPtr, 16);
|
||||||
|
const sequenceBefore = Atomics.load(control, 9);
|
||||||
|
|
||||||
|
material.update({ baseColor: [0.2, 0.4, 0.8, 1], metallic: 0.25, roughness: 0.75, ior: 2 });
|
||||||
|
|
||||||
|
assert.equal(material.key, 1);
|
||||||
|
assert.deepEqual(material.baseColor.map(value => Math.round(value * 10) / 10), [0.2, 0.4, 0.8, 1]);
|
||||||
|
assert.equal(material.metallic, 0.25);
|
||||||
|
assert.equal(material.roughness, 0.75);
|
||||||
|
assert.equal(material.ior, 2);
|
||||||
|
assert.equal(Atomics.load(fixture.ring, 5), ringBefore);
|
||||||
|
assert.equal(Atomics.load(control, 9), sequenceBefore + 2);
|
||||||
|
});
|
||||||
|
|
||||||
test("generation columns reject stale handles and are read-only", async () => {
|
test("generation columns reject stale handles and are read-only", async () => {
|
||||||
const fixture = setup();
|
const fixture = setup();
|
||||||
const mesh = await imported(fixture);
|
const mesh = await imported(fixture);
|
||||||
@@ -221,6 +280,7 @@ test("graph lifecycle remains FIFO and uses opcodes 8 and 9", async () => {
|
|||||||
const first = fixture.core.switchCompiledGraph([9, 4]);
|
const first = fixture.core.switchCompiledGraph([9, 4]);
|
||||||
const second = fixture.core.dropCompiledGraph([2, 1]);
|
const second = fixture.core.dropCompiledGraph([2, 1]);
|
||||||
assert.equal(Atomics.load(fixture.ring, 5), 1);
|
assert.equal(Atomics.load(fixture.ring, 5), 1);
|
||||||
|
assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 5)], [2, 9, 1, 9, 4]);
|
||||||
fixture.worker.reply({ type: "reply", request: 1, ok: true });
|
fixture.worker.reply({ type: "reply", request: 1, ok: true });
|
||||||
await first;
|
await first;
|
||||||
await new Promise(queueMicrotask);
|
await new Promise(queueMicrotask);
|
||||||
|
|||||||
+26
-11
@@ -12,9 +12,10 @@ const portalHost = process.env.PUBLIC_URL
|
|||||||
? new URL(process.env.PUBLIC_URL).hostname
|
? new URL(process.env.PUBLIC_URL).hostname
|
||||||
: undefined;
|
: undefined;
|
||||||
const serverPort = Number(process.env.PORT) || 8080;
|
const serverPort = Number(process.env.PORT) || 8080;
|
||||||
const wasmSource = path.resolve(__dirname, "level-editor/pkg");
|
const wasmSource = path.resolve(__dirname, "renderer/pkg");
|
||||||
const exampleRoot = path.resolve(__dirname, "examples/render-graph-studio");
|
const exampleRoot = path.resolve(__dirname, "examples");
|
||||||
const wasmDestination = path.resolve(exampleRoot, "level-editor/pkg");
|
const wasmDestination = path.resolve(exampleRoot, "renderer/pkg");
|
||||||
|
const buildOutput = path.resolve(__dirname, "dist");
|
||||||
|
|
||||||
// Vite resolves entry imports before plugin build hooks. Mirror synchronously while
|
// Vite resolves entry imports before plugin build hooks. Mirror synchronously while
|
||||||
// loading the config so a clean build always sees the complete generated package.
|
// loading the config so a clean build always sees the complete generated package.
|
||||||
@@ -29,6 +30,21 @@ function freshWasmPackage() {
|
|||||||
return {
|
return {
|
||||||
name: "fresh-wasm-package",
|
name: "fresh-wasm-package",
|
||||||
async buildStart() { await mirror(); },
|
async buildStart() { await mirror(); },
|
||||||
|
async writeBundle() {
|
||||||
|
const wasmBuildDestination = path.resolve(buildOutput, "renderer/pkg");
|
||||||
|
const cookbookBuildDestination = path.resolve(buildOutput, "cookbook");
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(wasmBuildDestination, { recursive: true }),
|
||||||
|
mkdir(cookbookBuildDestination, { recursive: true }),
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
cp(wasmSource, wasmBuildDestination, { recursive: true, force: true }),
|
||||||
|
cp(path.resolve(exampleRoot, "cookbook"), cookbookBuildDestination, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.watcher.add(wasmSource);
|
server.watcher.add(wasmSource);
|
||||||
const update = file => {
|
const update = file => {
|
||||||
@@ -46,14 +62,18 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
app: path.resolve(exampleRoot, "index.html"),
|
examples: path.resolve(exampleRoot, "index.html"),
|
||||||
|
renderGraphStudio: path.resolve(
|
||||||
|
exampleRoot,
|
||||||
|
"render-graph-studio/index.html",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Relative to 'root'.
|
// Relative to 'root'.
|
||||||
outDir: "../../dist",
|
outDir: "../dist",
|
||||||
copyPublicDir: true,
|
copyPublicDir: true,
|
||||||
},
|
},
|
||||||
// Build output stays at the repository root, outside the nested example root.
|
// Build output stays at the repository root, outside the examples root.
|
||||||
root: exampleRoot,
|
root: exampleRoot,
|
||||||
// The example is source code, not Vite's untransformed public directory. Treating it
|
// The example is source code, not Vite's untransformed public directory. Treating it
|
||||||
// as both makes dev mode reject the generated WASM worker's module imports.
|
// as both makes dev mode reject the generated WASM worker's module imports.
|
||||||
@@ -80,10 +100,6 @@ export default defineConfig({
|
|||||||
__dirname,
|
__dirname,
|
||||||
"addons/render-graph-fxnode/src/index.js",
|
"addons/render-graph-fxnode/src/index.js",
|
||||||
),
|
),
|
||||||
"@yawn/core/snapshot": path.resolve(
|
|
||||||
__dirname,
|
|
||||||
"packages/yawn-core/src/snapshot.js",
|
|
||||||
),
|
|
||||||
"@yawn/core": path.resolve(__dirname, "packages/yawn-core/src/index.js"),
|
"@yawn/core": path.resolve(__dirname, "packages/yawn-core/src/index.js"),
|
||||||
"@yawn/mesh-handles": path.resolve(
|
"@yawn/mesh-handles": path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
@@ -97,7 +113,6 @@ export default defineConfig({
|
|||||||
__dirname,
|
__dirname,
|
||||||
"addons/default-pipelines/src/index.js",
|
"addons/default-pipelines/src/index.js",
|
||||||
),
|
),
|
||||||
pkg: path.resolve(__dirname, "level-editor/pkg"),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
Reference in New Issue
Block a user